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

Linked Web Storage: /" + safePath + "

"); - out.println("

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


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

Empty container.

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

    Linked Web Storage: /" + safePath + "

    "); + out.println("

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


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

    Empty container.

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

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

    "); + } + out.println(""); + } + return; + } + if (wantJson && !wantTurtle) { + // LWS container representation: identical body for all three JSON + // flavors, only the Content-Type varies (spec MUST). + String ct = forceJsonLd ? "application/ld+json" + : accept.contains("lws+json") ? LWS_JSON + : accept.contains("ld+json") ? "application/ld+json" + : "application/json"; + resp.setContentType(ct); + resp.setCharacterEncoding("UTF-8"); + if (!isHead) resp.getWriter().write( + containerJson(liveSelf, total, pageItems, firstUri, lastUri, nextUri, prevUri)); + return; + } + if (wantTurtle) { + // Additional RDF representation: the same information as the LWS + // shape, expressed as model triples (page-scoped items). + Model out = ModelFactory.createDefaultModel(); + Resource httpR = out.createResource(liveSelf); + r.listProperties().forEachRemaining(s -> { + RDFNode obj = s.getObject(); + if (!exposableToClient(obj)) return; // never leak file:/// server paths + if (s.getPredicate().equals(LWS_ITEMS)) return; // page-scoped below + if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { + httpR.addProperty(s.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); + } else { + httpR.addProperty(s.getPredicate(), obj); } - out.println("
"); + }); + // Body-embedded navigation, mirroring the Link headers. + if (firstUri != null) httpR.addProperty(AS_FIRST, out.createResource(firstUri)); + if (lastUri != null) httpR.addProperty(AS_LAST, out.createResource(lastUri)); + if (nextUri != null) httpR.addProperty(AS_NEXT, out.createResource(nextUri)); + if (prevUri != null) httpR.addProperty(AS_PREV, out.createResource(prevUri)); + for (Resource it : pageItems) { + String itHttp = toLiveUri(it.getURI()); + httpR.addProperty(LWS_ITEMS, out.createResource(itHttp)); + it.listProperties().forEachRemaining(st -> { + RDFNode obj = st.getObject(); + if (!exposableToClient(obj)) return; // never leak file:/// server paths + if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { + out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); + } else { + out.getResource(itHttp).addProperty(st.getPredicate(), obj); + } + }); } - out.println(""); + resp.setContentType("text/turtle"); + if (!isHead) RDFDataMgr.write(resp.getOutputStream(), out, RDFFormat.TURTLE); + return; } + resp.sendError(406); return; } - if (wantTurtle || wantJson) { + + // ---- Non-container (data resource) ---- + if (wantTurtle || (wantJson && !wantHtml)) { + // RDF description of the data resource (its metadata triples). Model out = ModelFactory.createDefaultModel(); Resource httpR = out.createResource(BASE + (reqPath.isEmpty() ? "" : reqPath)); r.listProperties().forEachRemaining(s -> { @@ -420,72 +796,10 @@ && isHDF5(h5Candidate) && isSparqlRequest(req)) { httpR.addProperty(s.getPredicate(), obj); } }); - if (isContainer) { - List items = r.listProperties(LWS_ITEMS).mapWith(Statement::getResource).toList(); - String pageStr = req.getParameter("page"); - if (pageStr != null) { - int page; - try { - page = Integer.parseInt(pageStr.trim()); - } catch (NumberFormatException e) { - resp.sendError(400, "Invalid 'page' parameter: " + pageStr); - return; - } - if (page < 1) { resp.sendError(400, "'page' must be >= 1"); return; } - int size = 20; - int total = items.size(); - long start = (long)(page-1) * size; // long: a huge page must not overflow to a negative index - if (start >= total) { resp.sendError(404); return; } - int startIdx = (int) start; - List paged = items.subList(startIdx, Math.min(startIdx+size, total)); - // Self and first/last/prev/next all use the same URI shape, - // and the navigation links are RESOURCES (they are page URIs, - // and were emitted as plain literals with a divergent shape). - Resource pageR = out.createResource(pageUri(reqPath, page)); - r.listProperties().forEachRemaining(s -> { - if (!s.getPredicate().equals(LWS_ITEMS) && exposableToClient(s.getObject())) { - pageR.addProperty(s.getPredicate(), s.getObject()); - } - }); - pageR.addProperty(RDF.type, LWS.ContainerPage); - pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#first"), out.createResource(pageUri(reqPath, 1))); - int pages = (total + size - 1) / size; - pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#last"), out.createResource(pageUri(reqPath, pages))); - if (page > 1) pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#prev"), out.createResource(pageUri(reqPath, page-1))); - if (page < pages) pageR.addProperty(ResourceFactory.createProperty("https://www.w3.org/ns/activitystreams#next"), out.createResource(pageUri(reqPath, page+1))); - for (Resource it : paged) { - String itHttp = toLiveUri(it.getURI()); - pageR.addProperty(LWS_ITEMS, out.createResource(itHttp)); - it.listProperties().forEachRemaining(st -> { - RDFNode obj = st.getObject(); - if (!exposableToClient(obj)) return; // never leak file:/// server paths - if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { - out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); - } else { - out.getResource(itHttp).addProperty(st.getPredicate(), obj); - } - }); - } - } else { - for (Resource it : items) { - String itHttp = toLiveUri(it.getURI()); - it.listProperties().forEachRemaining(st -> { - RDFNode obj = st.getObject(); - if (!exposableToClient(obj)) return; // never leak file:/// server paths - if (obj.isResource() && obj.asResource().getURI() != null && obj.asResource().getURI().startsWith(HTTP_ROOT)) { - out.getResource(itHttp).addProperty(st.getPredicate(), out.createResource(toLiveUri(obj.asResource().getURI()))); - } else { - out.getResource(itHttp).addProperty(st.getPredicate(), obj); - } - }); - } - } - } resp.setContentType(wantTurtle ? "text/turtle" : "application/ld+json"); - RDFDataMgr.write(resp.getOutputStream(), out, wantTurtle ? RDFFormat.TURTLE : RDFFormat.JSONLD); + if (!isHead) RDFDataMgr.write(resp.getOutputStream(), out, wantTurtle ? RDFFormat.TURTLE : RDFFormat.JSONLD); return; } - if (isContainer) { resp.sendError(406); return; } if (STORAGE_ROOT == null) { resp.sendError(500, "Storage root not set"); return; } Path localFile = resolveWithin(STORAGE_ROOT, reqPath); if (localFile == null) { resp.sendError(403, "Forbidden"); return; } @@ -508,24 +822,60 @@ && isHDF5(h5Candidate) && isSparqlRequest(req)) { String etag = "\"" + size + "-" + lastModified + "\""; resp.setHeader("ETag", etag); resp.setDateHeader("Last-Modified", lastModified); - String ifNoneMatch = req.getHeader("If-None-Match"); - long ifModifiedSince = req.getDateHeader("If-Modified-Since"); - if (etag.equals(ifNoneMatch) - || (ifNoneMatch == null && ifModifiedSince >= 0 && lastModified / 1000 <= ifModifiedSince / 1000)) { - resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); - return; - } + resp.setHeader("Accept-Ranges", "bytes"); + if (notModified(req, resp, etag, lastModified)) return; resp.setContentType(media); // Stored bytes are served as a download: with nosniff above, this keeps an // HTML/SVG file someone placed under the root from rendering in this origin. String filename = localFile.getFileName().toString().replace("\"", ""); resp.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + + // ---- Range requests (RFC 9110 / LWS MUST). Single ranges only; anything + // else is served whole, which RFC 9110 permits. Range is defined for GET. + long[] range = isHead ? null : parseRange(req.getHeader("Range"), size); + String ifRange = req.getHeader("If-Range"); + if (range != null && ifRange != null && !ifRange.equals(etag)) { + range = null; // validator changed: send the full representation + } + if (range != null && range[0] == -1) { + // setStatus, not sendError: sendError may reset the buffer/headers and + // the 416 MUST carry Content-Range: bytes */size. + resp.setStatus(416); + resp.setHeader("Content-Range", "bytes */" + size); + resp.setContentLength(0); + return; + } + if (range != null) { + long start = range[0]; + long end = range[1]; + long len = end - start + 1; + resp.setStatus(206); + resp.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + size); + resp.setContentLengthLong(len); + try (InputStream in = Files.newInputStream(localFile)) { + in.skipNBytes(start); + copyBounded(in, resp.getOutputStream(), len); + } + return; + } resp.setContentLengthLong(size); // HEAD gets the same headers without the body (and without the file copy). - if (!"HEAD".equals(req.getMethod())) { + if (!isHead) { Files.copy(localFile, resp.getOutputStream()); } } + + private static void copyBounded(InputStream in, OutputStream out, long len) throws IOException { + byte[] buf = new byte[64 * 1024]; + long remaining = len; + while (remaining > 0) { + int n = in.read(buf, 0, (int) Math.min(buf.length, remaining)); + if (n < 0) throw new EOFException("File shrank while serving range"); + out.write(buf, 0, n); + remaining -= n; + } + } + @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setHeader("X-Content-Type-Options", "nosniff"); @@ -562,8 +912,8 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } @Override protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException { - // Same routing and headers as GET; the file-serving branch checks the - // method and skips the body copy for HEAD. + // Same routing and headers as GET; each body-writing branch checks the + // method and skips the body for HEAD. doGet(req, resp); } } diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java new file mode 100644 index 00000000..bb1e3a1d --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java @@ -0,0 +1,341 @@ +package com.ebremer.beakgraph.core.fuseki; + +import com.ebremer.beakgraph.lws.LWSMetadataGenerator; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * HTTP-level conformance of the unauthenticated read surface against the LWS + * Protocol 1.0 draft (w3c.github.io/lws-protocol/lws10-core/): container + * representation shape and media-type negotiation, link-based pagination + * (first/last/next/prev in Link headers, full totalItems with page-scoped + * items), mandatory Link relations (type / up / linkset / lws#storageDescription), + * validators + conditional requests on containers, and single-range requests + * on data resources. Runs a real Jetty server so header semantics are the + * container's, not a mock's. + */ +class LWSReadComplianceTest { + + @TempDir + static Path dir; + + private static Server server; + private static String base; + private static final HttpClient http = HttpClient.newHttpClient(); + private static final int FILES = 45; + /** Enough members to span 3+ pages at any PAGE_SIZE the guard admits. */ + private static final int BIGSUB_FILES = LWSStorageServlet.PAGE_SIZE * 2 + 2; + /** Root membership: FILES + the "sub" and "big sub" directories. */ + private static final int ROOT_ITEMS = FILES + 2; + + @BeforeAll + static void startServer() throws Exception { + Path root = Files.createDirectories(dir.resolve("storage")); + for (int i = 0; i < FILES; i++) { + Files.write(root.resolve(String.format("f%02d.txt", i)), + ("content-of-file-" + i).getBytes(StandardCharsets.UTF_8)); + } + Path sub = Files.createDirectories(root.resolve("sub")); + Files.write(sub.resolve("a.txt"), "aaa".getBytes(StandardCharsets.UTF_8)); + Files.write(sub.resolve("b.txt"), "bbb".getBytes(StandardCharsets.UTF_8)); + // A PAGINATING subcontainer whose name needs percent-encoding: pagination + // must work below the root, and its page URIs must be valid (my%20slides, + // not a raw space). + Path bigsub = Files.createDirectories(root.resolve("big sub")); + for (int i = 0; i < BIGSUB_FILES; i++) { + Files.write(bigsub.resolve(String.format("g%02d.txt", i)), + ("g-" + i).getBytes(StandardCharsets.UTF_8)); + } + + Model model = LWSMetadataGenerator.generateLWSModel(root); + server = new Server(0); + ServletContextHandler ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + ctx.addServlet(new ServletHolder(new LWSStorageServlet(model)), "/*"); + server.setHandler(ctx); + server.start(); + int port = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + base = "http://localhost:" + port + "/"; + LWSStorageServlet.setBase(base); + LWSStorageServlet.setStorageRoot(root); + } + + @AfterAll + static void stopServer() throws Exception { + if (server != null) server.stop(); + } + + private static HttpResponse get(String path, String... headers) throws Exception { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + path)); + for (int i = 0; i < headers.length; i += 2) { + b.header(headers[i], headers[i + 1]); + } + return http.send(b.GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + private static JsonObject parse(String json) { + try (JsonReader r = Json.createReader(new StringReader(json))) { + return r.readObject(); + } + } + + private static boolean hasLink(HttpResponse resp, String relFragment) { + return resp.headers().allValues("Link").stream().anyMatch(v -> v.contains(relFragment)); + } + + private static String linkTarget(HttpResponse resp, String relFragment) { + return resp.headers().allValues("Link").stream() + .filter(v -> v.contains(relFragment)) + .map(v -> v.substring(v.indexOf('<') + 1, v.indexOf('>'))) + .findFirst().orElse(null); + } + + @Test + void containerRepresentationHasTheLwsShape() throws Exception { + HttpResponse resp = get("", "Accept", "application/lws+json"); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("Content-Type").orElse("").startsWith("application/lws+json"), + "requested lws+json must be echoed"); + assertTrue(resp.headers().firstValue("ETag").isPresent(), "containers must carry an ETag"); + assertTrue(resp.headers().firstValue("Last-Modified").isPresent(), "containers must carry Last-Modified"); + assertTrue(hasLink(resp, "rel=\"type\""), "rel=type link required"); + assertTrue(hasLink(resp, "https://www.w3.org/ns/lws#Container"), "type link must name lws#Container"); + assertTrue(hasLink(resp, "rel=\"linkset\""), "rel=linkset link required"); + assertTrue(hasLink(resp, LWSStorageServlet.REL_STORAGE_DESCRIPTION), + "storage-description discovery link required on all storage responses"); + assertFalse(hasLink(resp, "rel=\"up\""), "the root container has no parent"); + + JsonObject doc = parse(resp.body()); + assertEquals("https://www.w3.org/ns/lws/v1", doc.getString("@context")); + assertEquals(base, doc.getString("id"), "id must be the container URI, not a page URI"); + assertEquals("Container", doc.getString("type")); + assertEquals(ROOT_ITEMS, doc.getInt("totalItems"), "totalItems reflects the FULL membership"); + assertEquals(LWSStorageServlet.PAGE_SIZE, doc.getJsonArray("items").size(), + "a large container's bare GET is the first page"); + // Item order is URI-sorted, so scan for one of each kind rather than + // assuming positions. + JsonObject file = null; + JsonObject folder = null; + for (jakarta.json.JsonValue v : doc.getJsonArray("items")) { + JsonObject o = v.asJsonObject(); + assertTrue(o.containsKey("id")); + if ("DataResource".equals(o.getString("type")) && file == null) file = o; + if ("Container".equals(o.getString("type")) && folder == null) folder = o; + } + assertNotNull(file, "page 1 must contain a DataResource"); + assertEquals("text/plain", file.getString("mediaType"), "mediaType is required for DataResources"); + assertTrue(file.containsKey("size")); + assertTrue(file.containsKey("modified")); + assertNotNull(folder, "page 1 must contain the 'big sub' Container (URI sort puts it first)"); + assertFalse(folder.containsKey("mediaType"), "containers carry no mediaType"); + } + + @Test + void subcontainersPaginateWithEncodedPageUris() throws Exception { + int pages = (BIGSUB_FILES + LWSStorageServlet.PAGE_SIZE - 1) / LWSStorageServlet.PAGE_SIZE; + assertTrue(pages >= 3, "bigsub must span at least 3 pages"); + + HttpResponse p1 = get("big%20sub", "Accept", "application/lws+json"); + assertEquals(200, p1.statusCode()); + assertTrue(hasLink(p1, "rel=\"first\""), "subcontainers must paginate exactly like the root"); + assertTrue(hasLink(p1, "rel=\"next\"")); + assertFalse(hasLink(p1, "rel=\"prev\"")); + + String next = linkTarget(p1, "rel=\"next\""); + assertNotNull(next); + assertTrue(next.contains("big%20sub"), + "page URIs must percent-encode the container path, got: " + next); + assertEquals(URI.create(next).toString(), next, "the next target must be a valid URI"); + + JsonObject d1 = parse(p1.body()); + assertEquals(BIGSUB_FILES, d1.getInt("totalItems")); + assertEquals(LWSStorageServlet.PAGE_SIZE, d1.getJsonArray("items").size()); + assertTrue(d1.containsKey("as:next"), "subcontainer bodies embed navigation too"); + assertEquals(next, d1.getJsonObject("as:next").getString("id")); + + // Follow the served link (opaque-URI navigation, as a client would). + HttpResponse p2 = get(next.substring(base.length()), "Accept", "application/lws+json"); + assertEquals(200, p2.statusCode()); + assertTrue(hasLink(p2, "rel=\"prev\"")); + assertEquals(BIGSUB_FILES, parse(p2.body()).getInt("totalItems")); + + HttpResponse pLast = get("big%20sub?page=" + pages, "Accept", "application/lws+json"); + assertEquals(200, pLast.statusCode()); + assertFalse(hasLink(pLast, "rel=\"next\""), "no next on the subcontainer's last page"); + assertEquals(BIGSUB_FILES - (pages - 1) * LWSStorageServlet.PAGE_SIZE, + parse(pLast.body()).getJsonArray("items").size()); + + // Listings must revalidate rather than replay from heuristic cache. + assertEquals("no-cache", p1.headers().firstValue("Cache-Control").orElse("")); + } + + @Test + void jsonFlavorsNegotiateContentTypeWithIdenticalBodies() throws Exception { + HttpResponse lws = get("", "Accept", "application/lws+json"); + HttpResponse ld = get("", "Accept", "application/ld+json"); + HttpResponse plain = get("", "Accept", "application/json"); + assertTrue(ld.headers().firstValue("Content-Type").orElse("").startsWith("application/ld+json")); + assertTrue(plain.headers().firstValue("Content-Type").orElse("").startsWith("application/json")); + assertEquals(lws.body(), ld.body(), "the payload must be identical across the JSON flavors"); + assertEquals(lws.body(), plain.body()); + } + + @Test + void paginationTravelsInLinkHeaders() throws Exception { + // Page-size agnostic: whatever PAGE_SIZE is configured, the root must + // paginate consistently (the storage tree guarantees > 2 pages). + int total = ROOT_ITEMS; + int pageSize = LWSStorageServlet.PAGE_SIZE; + int pages = (total + pageSize - 1) / pageSize; + assertTrue(pages >= 3, "test data must span at least 3 pages (adjust FILES if PAGE_SIZE grew)"); + + HttpResponse p1 = get("", "Accept", "application/lws+json"); + assertTrue(hasLink(p1, "rel=\"first\""), "first MUST be present on paginated responses"); + assertTrue(hasLink(p1, "rel=\"next\""), "next MUST be present when more pages exist"); + assertTrue(hasLink(p1, "rel=\"last\"")); + assertFalse(hasLink(p1, "rel=\"prev\""), "prev MUST be omitted on the first page"); + + String next = linkTarget(p1, "rel=\"next\""); + assertNotNull(next); + + // The body mirrors the Link-header navigation (as: node references). + JsonObject d1 = parse(p1.body()); + assertTrue(d1.containsKey("as:first"), "paginated bodies embed as:first"); + assertTrue(d1.containsKey("as:next"), "paginated bodies embed as:next"); + assertFalse(d1.containsKey("as:prev"), "no as:prev on the first page"); + assertEquals(next, d1.getJsonObject("as:next").getString("id"), + "the body's as:next must equal the Link header target"); + HttpResponse p2 = get(next.substring(base.length()), "Accept", "application/lws+json"); + assertEquals(200, p2.statusCode()); + assertTrue(hasLink(p2, "rel=\"prev\"")); + assertTrue(hasLink(p2, "rel=\"next\"")); + JsonObject d2 = parse(p2.body()); + assertEquals(total, d2.getInt("totalItems"), "totalItems stays the full count on every page"); + assertEquals(base, d2.getString("id"), "the body id stays the container URI on every page"); + assertEquals(pageSize, d2.getJsonArray("items").size()); + + HttpResponse pLast = get("?page=" + pages, "Accept", "application/lws+json"); + assertEquals(200, pLast.statusCode()); + assertFalse(hasLink(pLast, "rel=\"next\""), "next MUST be omitted on the last page"); + assertTrue(hasLink(pLast, "rel=\"prev\"")); + JsonObject dLast = parse(pLast.body()); + assertEquals(total - (pages - 1) * pageSize, dLast.getJsonArray("items").size()); + assertFalse(dLast.containsKey("as:next"), "no as:next on the last page"); + assertTrue(dLast.containsKey("as:prev"), "the last page embeds as:prev"); + + assertEquals(404, get("?page=" + (pages + 1), "Accept", "application/lws+json").statusCode()); + assertEquals(400, get("?page=0", "Accept", "application/lws+json").statusCode()); + assertEquals(400, get("?page=x", "Accept", "application/lws+json").statusCode()); + } + + @Test + void smallContainersAreNotPaginated() throws Exception { + HttpResponse resp = get("sub", "Accept", "application/lws+json"); + assertEquals(200, resp.statusCode()); + assertFalse(hasLink(resp, "rel=\"first\""), "below the threshold there is no pagination"); + JsonObject doc = parse(resp.body()); + assertEquals(2, doc.getInt("totalItems")); + assertEquals(2, doc.getJsonArray("items").size()); + assertFalse(doc.containsKey("as:first"), "unpaginated bodies carry no navigation"); + assertFalse(doc.containsKey("as:next")); + assertEquals(base, linkTarget(resp, "rel=\"up\""), "non-root resources must link rel=up to their parent"); + } + + @Test + void containerConditionalRequestsAnswer304() throws Exception { + HttpResponse resp = get("", "Accept", "application/lws+json"); + String etag = resp.headers().firstValue("ETag").orElseThrow(); + HttpResponse cond = get("", "Accept", "application/lws+json", "If-None-Match", etag); + assertEquals(304, cond.statusCode()); + } + + @Test + void dataResourcesServeRanges() throws Exception { + String content = "content-of-file-7"; + HttpResponse full = get("f07.txt", "Accept", "text/plain"); + assertEquals(200, full.statusCode()); + assertEquals(content, full.body()); + assertEquals("bytes", full.headers().firstValue("Accept-Ranges").orElse("")); + assertTrue(hasLink(full, "https://www.w3.org/ns/lws#DataResource"), "rel=type must name lws#DataResource"); + assertEquals(base, linkTarget(full, "rel=\"up\"")); + + HttpResponse part = get("f07.txt", "Accept", "text/plain", "Range", "bytes=2-6"); + assertEquals(206, part.statusCode()); + assertEquals("bytes 2-6/" + content.length(), + part.headers().firstValue("Content-Range").orElse("")); + assertEquals(content.substring(2, 7), part.body()); + + HttpResponse suffix = get("f07.txt", "Accept", "text/plain", "Range", "bytes=-4"); + assertEquals(206, suffix.statusCode()); + assertEquals(content.substring(content.length() - 4), suffix.body()); + + HttpResponse bad = get("f07.txt", "Accept", "text/plain", "Range", "bytes=999-"); + assertEquals(416, bad.statusCode()); + assertEquals("bytes */" + content.length(), + bad.headers().firstValue("Content-Range").orElse("")); + } + + @Test + void headReturnsHeadersWithoutBody() throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(base)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .header("Accept", "application/lws+json").build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("ETag").isPresent()); + assertTrue(hasLink(resp, "rel=\"type\"")); + assertEquals("", resp.body(), "HEAD must not carry a body"); + } + + @Test + void htmlViewOffersAParentLink() throws Exception { + HttpResponse sub = get("sub", "Accept", "text/html"); + assertEquals(200, sub.statusCode()); + assertTrue(sub.headers().firstValue("Content-Type").orElse("").startsWith("text/html")); + assertTrue(sub.body().contains("Parent"), "subcontainer HTML must offer a parent link"); + assertTrue(sub.body().contains("href=\"" + base + "\""), "the parent link must target the parent container"); + + HttpResponse root = get("", "Accept", "text/html"); + assertEquals(200, root.statusCode()); + assertFalse(root.body().contains("Parent"), "the root has no parent to link"); + } + + @Test + void storageDescriptionUsesTheLwsMediaType() throws Exception { + HttpResponse resp = get("description"); + assertEquals(200, resp.statusCode()); + assertTrue(resp.headers().firstValue("Content-Type").orElse("").startsWith("application/lws+json"), + "the storage description defaults to application/lws+json"); + JsonObject doc = parse(resp.body()); + assertEquals(base, doc.getString("id")); + assertEquals("Storage", doc.getString("type")); + assertTrue(hasLink(resp, LWSStorageServlet.REL_STORAGE_DESCRIPTION)); + + HttpResponse ld = get("description", "Accept", "application/ld+json"); + assertTrue(ld.headers().firstValue("Content-Type").orElse("").startsWith("application/ld+json")); + assertEquals(resp.body(), ld.body(), "negotiated flavors share one payload"); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java index 31633add..4804d30c 100644 --- a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSStorageServletTest.java @@ -46,11 +46,39 @@ void linksetJsonEscapesValuesAndIsWellFormed() { void descriptionJsonIsWellFormed() { JsonObject doc = parse(LWSStorageServlet.descriptionJson("https://base.example/")); assertEquals("Storage", doc.getString("type")); - assertEquals("https://base.example/", doc.getString("@id")); + // LWS Discovery data model: the storage is identified by "id" (the lws/v1 + // context maps it to @id), and the StorageDescription service is mandatory. + assertEquals("https://base.example/", doc.getString("id")); + assertEquals("StorageDescription", + doc.getJsonArray("service").getJsonObject(0).getString("type")); + assertEquals("https://base.example/description", + doc.getJsonArray("service").getJsonObject(0).getString("serviceEndpoint")); assertEquals("https://base.example/sparql", doc.getJsonArray("service").getJsonObject(1).getString("serviceEndpoint")); } + @Test + void parseRangeHandlesTheSingleRangeForms() { + long size = 100; + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{2, 5}, + LWSStorageServlet.parseRange("bytes=2-5", size)); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{90, 99}, + LWSStorageServlet.parseRange("bytes=90-", size), "open-ended range runs to EOF"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{90, 99}, + LWSStorageServlet.parseRange("bytes=-10", size), "suffix range takes the last N bytes"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{0, 99}, + LWSStorageServlet.parseRange("bytes=0-500", size), "end clamps to size-1"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{-1, -1}, + LWSStorageServlet.parseRange("bytes=100-", size), "start at/after EOF is unsatisfiable"); + org.junit.jupiter.api.Assertions.assertArrayEquals(new long[]{-1, -1}, + LWSStorageServlet.parseRange("bytes=-0", size), "empty suffix is unsatisfiable"); + assertNull(LWSStorageServlet.parseRange(null, size), "no header -> full response"); + assertNull(LWSStorageServlet.parseRange("bytes=0-1,5-9", size), "multi-range is ignored"); + assertNull(LWSStorageServlet.parseRange("items=0-5", size), "non-bytes unit is ignored"); + assertNull(LWSStorageServlet.parseRange("bytes=x-y", size), "garbage is ignored"); + assertNull(LWSStorageServlet.parseRange("bytes=5-2", size), "inverted range is ignored"); + } + @Test void resolveWithinRejectsTraversalAndAbsolutePaths() throws Exception { Path root = Files.createTempDirectory("lws-test").toRealPath(); From c21ada0bf4b27bbdd1c549f9cba0d2dba0ea7b41 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Thu, 2 Jul 2026 20:07:25 -0400 Subject: [PATCH 02/17] update TODO.md --- .gitignore | 2 ++ pom.xml | 11 ++++---- .../beakgraph/core/fuseki/SPARQLEndPoint.java | 4 +-- .../hdf5/jena/ExecutionContextBG.java | 26 ----------------- .../beakgraph/hdf5/jena/OpExecutorBG.java | 28 ++++++++++++------- .../beakgraph/hdf5/jena/PatternMatchBG.java | 6 ++-- .../beakgraph/TemporalTotalOrderTest.java | 4 ++- .../core/fuseki/LWSReadComplianceTest.java | 4 +-- 8 files changed, 37 insertions(+), 48 deletions(-) delete mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java diff --git a/.gitignore b/.gitignore index f614a7e6..93869936 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ nb-configuration.xml nbactions.xml nbactions-*.xml +REVIEW.md +TODO.md diff --git a/pom.xml b/pom.xml index 45a6c913..4d7201a9 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.ebremer BeakGraph BeakGraph - 0.16.0 + 0.17.0 jar Library for creating indexed binary storages for Apache Jena Graphs and Datasets @@ -40,7 +40,7 @@ com.ebremer.beakgraph.cmdline.BeakGraphCLI - 5.6.0 + 6.1.0 1.28.0 4.5.0 @@ -74,9 +74,10 @@ 3.6 2.13.1 2.21.0 - - 3.2.2 + + 3.2.3 3.9.0 diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java index e5a3842a..60416406 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/SPARQLEndPoint.java @@ -14,8 +14,8 @@ import org.apache.jena.riot.RDFFormat; import org.apache.jena.sparql.core.DatasetGraph; import org.apache.jena.sys.JenaSystem; -import org.eclipse.jetty.ee10.servlet.ServletContextHandler; -import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee11.servlet.ServletContextHandler; +import org.eclipse.jetty.ee11.servlet.ServletHolder; import org.eclipse.jetty.server.Server; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java deleted file mode 100644 index a7f72bd0..00000000 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/ExecutionContextBG.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.ebremer.beakgraph.hdf5.jena; - -import org.apache.jena.sparql.algebra.Op; -import org.apache.jena.sparql.algebra.op.OpFilter; -import org.apache.jena.sparql.engine.ExecutionContext; -import org.apache.jena.sparql.expr.ExprList; - -/** - * - * @author erich - */ -public class ExecutionContextBG extends ExecutionContext { - private final Op op; - - public ExecutionContextBG(ExecutionContext other, Op op) { - super(other); - this.op = op; - } - - public ExprList getFilter() { - if (op instanceof OpFilter opFilter) { - return opFilter.getExprs(); - } - return null; - } -} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java index 83860759..220e0994 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/OpExecutorBG.java @@ -112,27 +112,35 @@ private static BasicPattern reorder(BasicPattern pattern, QueryIterPeek peek, Re } private static QueryIterator plainExecute(Op op, QueryIterator input, ExecutionContext execCxt) { - ExecutionContextBG ec = new ExecutionContextBG(execCxt, op); - ec.setExecutor(plainFactory); + // Jena 6: ExecutionContext is final, so the placed filter can no longer + // ride on a context subclass (the old ExecutionContextBG). It rides on a + // per-call executor FACTORY instead - immutable and per-execution, so + // executors created lazily during iteration (e.g. substitution joins + // re-executing the RHS per binding) still see exactly their op's filter. + ExprList filter = (op instanceof OpFilter opFilter) ? opFilter.getExprs() : null; + ExecutionContext ec = ExecutionContext.copyChangeExecutor(execCxt, new OpExecutorPlainFactoryBeak(filter)); return QC.execute(op, input, ec) ; } - - private static final OpExecutorFactory plainFactory = new OpExecutorPlainFactoryBeak(); - + private static class OpExecutorPlainFactoryBeak implements OpExecutorFactory { + private final ExprList filter; + + OpExecutorPlainFactoryBeak(ExprList filter) { + this.filter = filter; + } + @Override public OpExecutor create(ExecutionContext execCxt) { - return new OpExecutorPlainBeak(execCxt) ; + return new OpExecutorPlainBeak(execCxt, filter) ; } } - + private static class OpExecutorPlainBeak extends OpExecutor { final ExprList filter; - public OpExecutorPlainBeak(ExecutionContext execCxt) { + public OpExecutorPlainBeak(ExecutionContext execCxt, ExprList filter) { super(execCxt); - ExecutionContextBG ecr = (ExecutionContextBG) execCxt; - filter = ecr.getFilter(); + this.filter = filter; } @Override diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java b/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java index 4ada20d2..e14f9174 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/jena/PatternMatchBG.java @@ -50,14 +50,16 @@ public static QueryIterator execute(BeakGraph bGraph, BasicPattern bgp, QueryIte // Execute all triple patterns (the ExprList is range-pushdown hints only; // full filter semantics are enforced by the surrounding OpFilter). + // Jena 6: makeAbortable takes the execution's cancel signal directly, + // mirroring Jena's own solvers. for (Triple triple : triples) { chain = solve(bGraph, triple, filter, chain, execCxt); - chain = makeAbortable(chain, killList); + chain = makeAbortable(chain, killList, execCxt.getCancelSignal()); } // Convert back to Jena bindings Iterator iterBinding = SolverLibBeak.convertToNodes(chain, bGraph); - iterBinding = makeAbortable(iterBinding, killList); + iterBinding = makeAbortable(iterBinding, killList, execCxt.getCancelSignal()); return new QueryIterAbortable(iterBinding, killList, input, execCxt); } diff --git a/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java b/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java index 7e3933eb..d50c8cb8 100644 --- a/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java +++ b/src/test/java/com/ebremer/beakgraph/TemporalTotalOrderTest.java @@ -91,7 +91,9 @@ void comparatorIsTotalOverMixedLiteralSpaces() { nodes.add(NodeFactory.createLiteralDT("42", XSDDatatype.XSDinteger)); nodes.add(NodeFactory.createLiteralDT("41.5", XSDDatatype.XSDdouble)); nodes.add(NodeFactory.createLiteralDT("not-a-date", XSDDatatype.XSDdateTime)); // ill-formed - nodes.add(NodeFactory.createLiteral("plain")); + // Jena 6 removed createLiteral(String); createLiteralString is the + // equivalent (a plain literal IS an xsd:string in RDF 1.1). + nodes.add(NodeFactory.createLiteralString("plain")); assertTotallyOrdered(nodes); diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java index bb1e3a1d..2fcdc340 100644 --- a/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/LWSReadComplianceTest.java @@ -14,8 +14,8 @@ import java.nio.file.Path; import java.util.List; import org.apache.jena.rdf.model.Model; -import org.eclipse.jetty.ee10.servlet.ServletContextHandler; -import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee11.servlet.ServletContextHandler; +import org.eclipse.jetty.ee11.servlet.ServletHolder; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import static org.junit.jupiter.api.Assertions.assertEquals; From 5b6cc089d4de663d44c5531a9aaa601415d0b749 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Thu, 2 Jul 2026 21:54:09 -0400 Subject: [PATCH 03/17] add FFM-based pure Java zstandard compressor --- .../ebremer/beakgraph/utils/StringUtils.java | 25 +- .../compress/v3/zstdFFM/BitInputStream.java | 209 ++++ .../compress/v3/zstdFFM/BitOutputStream.java | 92 ++ .../v3/zstdFFM/BlockCompressionState.java | 76 ++ .../compress/v3/zstdFFM/BlockCompressor.java | 23 + .../v3/zstdFFM/CompressionContext.java | 57 ++ .../v3/zstdFFM/CompressionParameters.java | 299 ++++++ .../compress/v3/zstdFFM/Constants.java | 83 ++ .../v3/zstdFFM/DoubleFastBlockCompressor.java | 251 +++++ .../airlift/compress/v3/zstdFFM/FfmUtil.java | 44 + .../v3/zstdFFM/FiniteStateEntropy.java | 534 ++++++++++ .../compress/v3/zstdFFM/FrameHeader.java | 86 ++ .../v3/zstdFFM/FseCompressionTable.java | 158 +++ .../compress/v3/zstdFFM/FseTableReader.java | 171 ++++ .../compress/v3/zstdFFM/Histogram.java | 65 ++ .../airlift/compress/v3/zstdFFM/Huffman.java | 327 ++++++ .../v3/zstdFFM/HuffmanCompressionContext.java | 61 ++ .../v3/zstdFFM/HuffmanCompressionTable.java | 396 +++++++ .../HuffmanCompressionTableWorkspace.java | 33 + .../v3/zstdFFM/HuffmanCompressor.java | 139 +++ .../zstdFFM/HuffmanTableWriterWorkspace.java | 29 + .../compress/v3/zstdFFM/NodeTable.java | 48 + .../io/airlift/compress/v3/zstdFFM/README.md | 25 + .../compress/v3/zstdFFM/RepeatedOffsets.java | 49 + .../compress/v3/zstdFFM/SequenceEncoder.java | 339 ++++++ .../v3/zstdFFM/SequenceEncodingContext.java | 30 + .../compress/v3/zstdFFM/SequenceStore.java | 159 +++ .../io/airlift/compress/v3/zstdFFM/Util.java | 136 +++ .../airlift/compress/v3/zstdFFM/XxHash64.java | 291 ++++++ .../compress/v3/zstdFFM/ZstdCompressor.java | 43 + .../compress/v3/zstdFFM/ZstdDecompressor.java | 30 + .../v3/zstdFFM/ZstdFrameCompressor.java | 432 ++++++++ .../v3/zstdFFM/ZstdFrameDecompressor.java | 968 ++++++++++++++++++ .../v3/zstdFFM/ZstdHadoopInputStream.java | 68 ++ .../v3/zstdFFM/ZstdHadoopOutputStream.java | 91 ++ .../v3/zstdFFM/ZstdHadoopStreams.java | 55 + .../ZstdIncrementalFrameDecompressor.java | 380 +++++++ .../compress/v3/zstdFFM/ZstdInputStream.java | 152 +++ .../v3/zstdFFM/ZstdJavaCompressor.java | 90 ++ .../v3/zstdFFM/ZstdJavaDecompressor.java | 87 ++ .../compress/v3/zstdFFM/ZstdNative.java | 190 ++++ .../v3/zstdFFM/ZstdNativeCompressor.java | 61 ++ .../v3/zstdFFM/ZstdNativeDecompressor.java | 53 + .../compress/v3/zstdFFM/ZstdOutputStream.java | 211 ++++ 44 files changed, 7129 insertions(+), 17 deletions(-) create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/README.md create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/Util.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java create mode 100644 src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java diff --git a/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java b/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java index fd6290ce..bcbf268a 100644 --- a/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java +++ b/src/main/java/com/ebremer/beakgraph/utils/StringUtils.java @@ -1,11 +1,7 @@ package com.ebremer.beakgraph.utils; -import io.airlift.compress.v3.zstd.ZstdCompressor; -import io.airlift.compress.v3.zstd.ZstdDecompressor; -import io.airlift.compress.v3.zstd.ZstdJavaCompressor; -import io.airlift.compress.v3.zstd.ZstdJavaDecompressor; -import io.airlift.compress.v3.zstd.ZstdNativeCompressor; -import io.airlift.compress.v3.zstd.ZstdNativeDecompressor; +import io.airlift.compress.v3.zstdFFM.ZstdCompressor; +import io.airlift.compress.v3.zstdFFM.ZstdDecompressor; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -20,19 +16,14 @@ public final class StringUtils { private static final int HEADER_SIZE = 4; // To store uncompressed length /** - * Selects the Zstd implementation: the native (Foreign Function and Memory - * API) binding when its bundled library is available, otherwise the - * pure-Java port. The native path avoids sun.misc.Unsafe (deprecated for - * removal); the Java path remains the portable fallback. + * Selects the Zstd implementation via the vendored zstdFFM package (see + * io/airlift/compress/v3/zstdFFM/README.md): the native binding when its + * bundled library loads, otherwise the FFM-based pure-Java port. Neither + * path touches sun.misc.Unsafe (deprecated for removal). */ public StringUtils() { - if (ZstdNativeCompressor.isEnabled()) { - COMPRESSOR = new ZstdNativeCompressor(); - DECOMPRESSOR = new ZstdNativeDecompressor(); - } else { - COMPRESSOR = new ZstdJavaCompressor(); - DECOMPRESSOR = new ZstdJavaDecompressor(); - } + COMPRESSOR = ZstdCompressor.create(); + DECOMPRESSOR = ZstdDecompressor.create(); } /** diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java new file mode 100644 index 00000000..e3fd8a5d --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BitInputStream.java @@ -0,0 +1,209 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +/** + * Bit streams are encoded as a byte-aligned little-endian stream. Thus, bits are laid out + * in the following manner, and the stream is read from right to left. + *

+ *

+ * ... [16 17 18 19 20 21 22 23] [8 9 10 11 12 13 14 15] [0 1 2 3 4 5 6 7] + */ +final class BitInputStream +{ + private BitInputStream() + { + } + + public static boolean isEndOfStream(long startAddress, long currentAddress, int bitsConsumed) + { + return startAddress == currentAddress && bitsConsumed == Long.SIZE; + } + + private static long readTail(MemorySegment inputBase, long inputAddress, int inputSize) + { + long bits = inputBase.get(JAVA_BYTE, inputAddress) & 0xFF; + + switch (inputSize) { + case 7: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 6) & 0xFFL) << 48; + case 6: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 5) & 0xFFL) << 40; + case 5: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 4) & 0xFFL) << 32; + case 4: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 3) & 0xFFL) << 24; + case 3: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 2) & 0xFFL) << 16; + case 2: + bits |= (inputBase.get(JAVA_BYTE, inputAddress + 1) & 0xFFL) << 8; + } + + return bits; + } + + /** + * @return numberOfBits in the low-order bits of a long + */ + public static long peekBits(int bitsConsumed, long bitContainer, int numberOfBits) + { + return (((bitContainer << bitsConsumed) >>> 1) >>> (63 - numberOfBits)); + } + + /** + * numberOfBits must be > 0 + * + * @return numberOfBits in the low-order bits of a long + */ + public static long peekBitsFast(int bitsConsumed, long bitContainer, int numberOfBits) + { + return ((bitContainer << bitsConsumed) >>> (64 - numberOfBits)); + } + + static class Initializer + { + private final MemorySegment inputBase; + private final long startAddress; + private final long endAddress; + private long bits; + private long currentAddress; + private int bitsConsumed; + + public Initializer(MemorySegment inputBase, long startAddress, long endAddress) + { + this.inputBase = inputBase; + this.startAddress = startAddress; + this.endAddress = endAddress; + } + + public long getBits() + { + return bits; + } + + public long getCurrentAddress() + { + return currentAddress; + } + + public int getBitsConsumed() + { + return bitsConsumed; + } + + public void initialize() + { + verify(endAddress - startAddress >= 1, startAddress, "Bitstream is empty"); + + int lastByte = inputBase.get(JAVA_BYTE, endAddress - 1) & 0xFF; + verify(lastByte != 0, endAddress, "Bitstream end mark not present"); + + bitsConsumed = SIZE_OF_LONG - highestBit(lastByte); + + int inputSize = (int) (endAddress - startAddress); + if (inputSize >= SIZE_OF_LONG) { /* normal case */ + currentAddress = endAddress - SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + else { + currentAddress = startAddress; + bits = readTail(inputBase, startAddress, inputSize); + + bitsConsumed += (SIZE_OF_LONG - inputSize) * 8; + } + } + } + + static final class Loader + { + private final MemorySegment inputBase; + private final long startAddress; + private long bits; + private long currentAddress; + private int bitsConsumed; + private boolean overflow; + + public Loader(MemorySegment inputBase, long startAddress, long currentAddress, long bits, int bitsConsumed) + { + this.inputBase = inputBase; + this.startAddress = startAddress; + this.bits = bits; + this.currentAddress = currentAddress; + this.bitsConsumed = bitsConsumed; + } + + public long getBits() + { + return bits; + } + + public long getCurrentAddress() + { + return currentAddress; + } + + public int getBitsConsumed() + { + return bitsConsumed; + } + + public boolean isOverflow() + { + return overflow; + } + + public boolean load() + { + if (bitsConsumed > 64) { + overflow = true; + return true; + } + + else if (currentAddress == startAddress) { + return true; + } + + int bytes = bitsConsumed >>> 3; // divide by 8 + if (currentAddress >= startAddress + SIZE_OF_LONG) { + if (bytes > 0) { + currentAddress -= bytes; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + bitsConsumed &= 0b111; + } + else if (currentAddress - bytes < startAddress) { + bytes = (int) (currentAddress - startAddress); + currentAddress = startAddress; + bitsConsumed -= bytes * SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, startAddress); + return true; + } + else { + currentAddress -= bytes; + bitsConsumed -= bytes * SIZE_OF_LONG; + bits = inputBase.get(JAVA_LONG, currentAddress); + } + + return false; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java new file mode 100644 index 00000000..74723d8e --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BitOutputStream.java @@ -0,0 +1,92 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; + +class BitOutputStream +{ + private static final long[] BIT_MASK = { + 0x0, 0x1, 0x3, 0x7, 0xF, 0x1F, + 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, + 0xFFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF, 0x1FFFF, + 0x3FFFF, 0x7FFFF, 0xFFFFF, 0x1FFFFF, 0x3FFFFF, 0x7FFFFF, + 0xFFFFFF, 0x1FFFFFF, 0x3FFFFFF, 0x7FFFFFF, 0xFFFFFFF, 0x1FFFFFFF, + 0x3FFFFFFF, 0x7FFFFFFF}; // up to 31 bits + + private final MemorySegment outputBase; + private final long outputAddress; + private final long outputLimit; + + private long container; + private int bitCount; + private long currentAddress; + + public BitOutputStream(MemorySegment outputBase, long outputAddress, int outputSize) + { + checkArgument(outputSize >= SIZE_OF_LONG, "Output buffer too small"); + + this.outputBase = outputBase; + this.outputAddress = outputAddress; + outputLimit = this.outputAddress + outputSize - SIZE_OF_LONG; + + currentAddress = this.outputAddress; + } + + public void addBits(int value, int bits) + { + container |= (value & BIT_MASK[bits]) << bitCount; + bitCount += bits; + } + + /** + * Note: leading bits of value must be 0 + */ + public void addBitsFast(int value, int bits) + { + container |= ((long) value) << bitCount; + bitCount += bits; + } + + public void flush() + { + int bytes = bitCount >>> 3; + + outputBase.set(JAVA_LONG, currentAddress, container); + currentAddress += bytes; + + if (currentAddress > outputLimit) { + currentAddress = outputLimit; + } + + bitCount &= 7; + container >>>= bytes * 8; + } + + public int close() + { + addBitsFast(1, 1); // end mark + flush(); + + if (currentAddress >= outputLimit) { + return 0; + } + + return (int) ((currentAddress - outputAddress) + (bitCount > 0 ? 1 : 0)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java new file mode 100644 index 00000000..3d64f587 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressionState.java @@ -0,0 +1,76 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class BlockCompressionState +{ + public final int[] hashTable; + public final int[] chainTable; + + private final long baseAddress; + + // starting point of the window with respect to baseAddress + private int windowBaseOffset; + + public BlockCompressionState(CompressionParameters parameters, long baseAddress) + { + this.baseAddress = baseAddress; + hashTable = new int[1 << parameters.getHashLog()]; + chainTable = new int[1 << parameters.getChainLog()]; // TODO: chain table not used by Strategy.FAST + } + + public void slideWindow(int slideWindowSize) + { + for (int i = 0; i < hashTable.length; i++) { + int newValue = hashTable[i] - slideWindowSize; + // if new value is negative, set it to zero branchless + newValue = newValue & (~(newValue >> 31)); + hashTable[i] = newValue; + } + for (int i = 0; i < chainTable.length; i++) { + int newValue = chainTable[i] - slideWindowSize; + // if new value is negative, set it to zero branchless + newValue = newValue & (~(newValue >> 31)); + chainTable[i] = newValue; + } + } + + public void reset() + { + Arrays.fill(hashTable, 0); + Arrays.fill(chainTable, 0); + } + + public void enforceMaxDistance(long inputLimit, int maxDistance) + { + int distance = (int) (inputLimit - baseAddress); + + int newOffset = distance - maxDistance; + if (windowBaseOffset < newOffset) { + windowBaseOffset = newOffset; + } + } + + public long getBaseAddress() + { + return baseAddress; + } + + public int getWindowBaseOffset() + { + return windowBaseOffset; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java new file mode 100644 index 00000000..6381edd2 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/BlockCompressor.java @@ -0,0 +1,23 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +interface BlockCompressor +{ + BlockCompressor UNSUPPORTED = (inputBase, inputAddress, inputSize, sequenceStore, blockCompressionState, offsets, parameters) -> { throw new UnsupportedOperationException(); }; + + int compressBlock(MemorySegment inputBase, long inputAddress, int inputSize, SequenceStore output, BlockCompressionState state, RepeatedOffsets offsets, CompressionParameters parameters); +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java new file mode 100644 index 00000000..e837bebf --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionContext.java @@ -0,0 +1,57 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static java.lang.Math.clamp; + +class CompressionContext +{ + public final CompressionParameters parameters; + public final RepeatedOffsets offsets = new RepeatedOffsets(); + public final BlockCompressionState blockCompressionState; + public final SequenceStore sequenceStore; + + public final SequenceEncodingContext sequenceEncodingContext = new SequenceEncodingContext(); + + public final HuffmanCompressionContext huffmanContext = new HuffmanCompressionContext(); + + public CompressionContext(CompressionParameters parameters, long baseAddress, int inputSize) + { + this.parameters = parameters; + + int windowSize = clamp(inputSize, 1, parameters.getWindowSize()); + int blockSize = Math.min(MAX_BLOCK_SIZE, windowSize); + int divider = (parameters.getSearchLength() == 3) ? 3 : 4; + + int maxSequences = blockSize / divider; + + sequenceStore = new SequenceStore(blockSize, maxSequences); + + blockCompressionState = new BlockCompressionState(parameters, baseAddress); + } + + public void slideWindow(int slideWindowSize) + { + checkArgument(slideWindowSize > 0, "slideWindowSize must be positive"); + blockCompressionState.slideWindow(slideWindowSize); + } + + public void commit() + { + offsets.commit(); + huffmanContext.saveChanges(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java new file mode 100644 index 00000000..21ab98a6 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/CompressionParameters.java @@ -0,0 +1,299 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.cycleLog; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static java.lang.Math.clamp; + +class CompressionParameters +{ + private static final int MIN_HASH_LOG = 6; + + public static final int DEFAULT_COMPRESSION_LEVEL = 3; + private static final int MAX_COMPRESSION_LEVEL = 22; + + private final int windowLog; // largest match distance : larger == more compression, more memory needed during decompression + private final int windowSize; // computed: 1 << windowLog + private final int blockSize; // computed: min(MAX_BLOCK_SIZE, windowSize) + private final int chainLog; // fully searched segment : larger == more compression, slower, more memory (useless for fast) + private final int hashLog; // dispatch table : larger == faster, more memory + private final int searchLog; // nb of searches : larger == more compression, slower + private final int searchLength; // match length searched : larger == faster decompression, sometimes less compression + private final int targetLength; // acceptable match size for optimal parser (only) : larger == more compression, slower + private final Strategy strategy; + + private static final CompressionParameters[][] DEFAULT_COMPRESSION_PARAMETERS = new CompressionParameters[][] { + { + // default + new CompressionParameters(19, 12, 13, 1, 6, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(19, 13, 14, 1, 7, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(19, 15, 16, 1, 6, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(20, 16, 17, 1, 5, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(20, 18, 18, 1, 5, 1, Strategy.DFAST), /* level 4 */ + new CompressionParameters(20, 18, 18, 2, 5, 2, Strategy.GREEDY), /* level 5 */ + new CompressionParameters(21, 18, 19, 2, 5, 4, Strategy.LAZY), /* level 6 */ + new CompressionParameters(21, 18, 19, 3, 5, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(21, 19, 19, 3, 5, 16, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(21, 19, 20, 4, 5, 16, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(21, 20, 21, 4, 5, 16, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(21, 21, 22, 4, 5, 16, Strategy.LAZY2), /* level 11 */ + new CompressionParameters(22, 20, 22, 5, 5, 16, Strategy.LAZY2), /* level 12 */ + new CompressionParameters(22, 21, 22, 4, 5, 32, Strategy.BTLAZY2), /* level 13 */ + new CompressionParameters(22, 21, 22, 5, 5, 32, Strategy.BTLAZY2), /* level 14 */ + new CompressionParameters(22, 22, 22, 6, 5, 32, Strategy.BTLAZY2), /* level 15 */ + new CompressionParameters(22, 21, 22, 4, 5, 48, Strategy.BTOPT), /* level 16 */ + new CompressionParameters(23, 22, 22, 4, 4, 64, Strategy.BTOPT), /* level 17 */ + new CompressionParameters(23, 23, 22, 6, 3, 256, Strategy.BTOPT), /* level 18 */ + new CompressionParameters(23, 24, 22, 7, 3, 256, Strategy.BTULTRA), /* level 19 */ + new CompressionParameters(25, 25, 23, 7, 3, 256, Strategy.BTULTRA), /* level 20 */ + new CompressionParameters(26, 26, 24, 7, 3, 512, Strategy.BTULTRA), /* level 21 */ + new CompressionParameters(27, 27, 25, 9, 3, 999, Strategy.BTULTRA) /* level 22 */ + }, + { + // for size <= 256 KB + new CompressionParameters(18, 12, 13, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(18, 13, 14, 1, 6, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(18, 14, 14, 1, 5, 1, Strategy.DFAST), /* level 2 */ + new CompressionParameters(18, 16, 16, 1, 4, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(18, 16, 17, 2, 5, 2, Strategy.GREEDY), /* level 4.*/ + new CompressionParameters(18, 18, 18, 3, 5, 2, Strategy.GREEDY), /* level 5.*/ + new CompressionParameters(18, 18, 19, 3, 5, 4, Strategy.LAZY), /* level 6.*/ + new CompressionParameters(18, 18, 19, 4, 4, 4, Strategy.LAZY), /* level 7 */ + new CompressionParameters(18, 18, 19, 4, 4, 8, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(18, 18, 19, 5, 4, 8, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(18, 18, 19, 6, 4, 8, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(18, 18, 19, 5, 4, 16, Strategy.BTLAZY2), /* level 11.*/ + new CompressionParameters(18, 19, 19, 6, 4, 16, Strategy.BTLAZY2), /* level 12.*/ + new CompressionParameters(18, 19, 19, 8, 4, 16, Strategy.BTLAZY2), /* level 13 */ + new CompressionParameters(18, 18, 19, 4, 4, 24, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(18, 18, 19, 4, 3, 24, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(18, 19, 19, 6, 3, 64, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(18, 19, 19, 8, 3, 128, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(18, 19, 19, 10, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(18, 19, 19, 10, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(18, 19, 19, 11, 3, 512, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(18, 19, 19, 12, 3, 512, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(18, 19, 19, 13, 3, 999, Strategy.BTULTRA) /* level 22.*/ + }, + { + // for size <= 128 KB + new CompressionParameters(17, 12, 12, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(17, 12, 13, 1, 6, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(17, 13, 15, 1, 5, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(17, 15, 16, 2, 5, 1, Strategy.DFAST), /* level 3 */ + new CompressionParameters(17, 17, 17, 2, 4, 1, Strategy.DFAST), /* level 4 */ + new CompressionParameters(17, 16, 17, 3, 4, 2, Strategy.GREEDY), /* level 5 */ + new CompressionParameters(17, 17, 17, 3, 4, 4, Strategy.LAZY), /* level 6 */ + new CompressionParameters(17, 17, 17, 3, 4, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(17, 17, 17, 4, 4, 8, Strategy.LAZY2), /* level 8 */ + new CompressionParameters(17, 17, 17, 5, 4, 8, Strategy.LAZY2), /* level 9 */ + new CompressionParameters(17, 17, 17, 6, 4, 8, Strategy.LAZY2), /* level 10 */ + new CompressionParameters(17, 17, 17, 7, 4, 8, Strategy.LAZY2), /* level 11 */ + new CompressionParameters(17, 18, 17, 6, 4, 16, Strategy.BTLAZY2), /* level 12 */ + new CompressionParameters(17, 18, 17, 8, 4, 16, Strategy.BTLAZY2), /* level 13.*/ + new CompressionParameters(17, 18, 17, 4, 4, 32, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(17, 18, 17, 6, 3, 64, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(17, 18, 17, 7, 3, 128, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(17, 18, 17, 7, 3, 256, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(17, 18, 17, 8, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(17, 18, 17, 8, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(17, 18, 17, 9, 3, 256, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(17, 18, 17, 10, 3, 256, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(17, 18, 17, 11, 3, 512, Strategy.BTULTRA) /* level 22.*/ + }, + { + // for size <= 16 KB + new CompressionParameters(14, 12, 13, 1, 5, 1, Strategy.FAST), /* base for negative levels */ + new CompressionParameters(14, 14, 15, 1, 5, 0, Strategy.FAST), /* level 1 */ + new CompressionParameters(14, 14, 15, 1, 4, 0, Strategy.FAST), /* level 2 */ + new CompressionParameters(14, 14, 14, 2, 4, 1, Strategy.DFAST), /* level 3.*/ + new CompressionParameters(14, 14, 14, 4, 4, 2, Strategy.GREEDY), /* level 4.*/ + new CompressionParameters(14, 14, 14, 3, 4, 4, Strategy.LAZY), /* level 5.*/ + new CompressionParameters(14, 14, 14, 4, 4, 8, Strategy.LAZY2), /* level 6 */ + new CompressionParameters(14, 14, 14, 6, 4, 8, Strategy.LAZY2), /* level 7 */ + new CompressionParameters(14, 14, 14, 8, 4, 8, Strategy.LAZY2), /* level 8.*/ + new CompressionParameters(14, 15, 14, 5, 4, 8, Strategy.BTLAZY2), /* level 9.*/ + new CompressionParameters(14, 15, 14, 9, 4, 8, Strategy.BTLAZY2), /* level 10.*/ + new CompressionParameters(14, 15, 14, 3, 4, 12, Strategy.BTOPT), /* level 11.*/ + new CompressionParameters(14, 15, 14, 6, 3, 16, Strategy.BTOPT), /* level 12.*/ + new CompressionParameters(14, 15, 14, 6, 3, 24, Strategy.BTOPT), /* level 13.*/ + new CompressionParameters(14, 15, 15, 6, 3, 48, Strategy.BTOPT), /* level 14.*/ + new CompressionParameters(14, 15, 15, 6, 3, 64, Strategy.BTOPT), /* level 15.*/ + new CompressionParameters(14, 15, 15, 6, 3, 96, Strategy.BTOPT), /* level 16.*/ + new CompressionParameters(14, 15, 15, 6, 3, 128, Strategy.BTOPT), /* level 17.*/ + new CompressionParameters(14, 15, 15, 8, 3, 256, Strategy.BTOPT), /* level 18.*/ + new CompressionParameters(14, 15, 15, 6, 3, 256, Strategy.BTULTRA), /* level 19.*/ + new CompressionParameters(14, 15, 15, 8, 3, 256, Strategy.BTULTRA), /* level 20.*/ + new CompressionParameters(14, 15, 15, 9, 3, 256, Strategy.BTULTRA), /* level 21.*/ + new CompressionParameters(14, 15, 15, 10, 3, 512, Strategy.BTULTRA) /* level 22.*/ + } + }; + + public enum Strategy + { + // from faster to stronger + + FAST(BlockCompressor.UNSUPPORTED), + DFAST(new DoubleFastBlockCompressor()), + GREEDY(BlockCompressor.UNSUPPORTED), + LAZY(BlockCompressor.UNSUPPORTED), + LAZY2(BlockCompressor.UNSUPPORTED), + BTLAZY2(BlockCompressor.UNSUPPORTED), + BTOPT(BlockCompressor.UNSUPPORTED), + BTULTRA(BlockCompressor.UNSUPPORTED); + + private final BlockCompressor compressor; + + Strategy(BlockCompressor compressor) + { + this.compressor = compressor; + } + + public BlockCompressor getCompressor() + { + return compressor; + } + } + + public CompressionParameters(int windowLog, int chainLog, int hashLog, int searchLog, int searchLength, int targetLength, Strategy strategy) + { + this.windowLog = windowLog; + this.windowSize = 1 << windowLog; + this.blockSize = Math.min(MAX_BLOCK_SIZE, windowSize); + this.chainLog = chainLog; + this.hashLog = hashLog; + this.searchLog = searchLog; + this.searchLength = searchLength; + this.targetLength = targetLength; + this.strategy = strategy; + } + + public int getWindowLog() + { + return windowLog; + } + + public int getWindowSize() + { + return windowSize; + } + + public int getBlockSize() + { + return blockSize; + } + + public int getSearchLength() + { + return searchLength; + } + + public int getChainLog() + { + return chainLog; + } + + public int getHashLog() + { + return hashLog; + } + + public int getSearchLog() + { + return searchLog; + } + + public int getTargetLength() + { + return targetLength; + } + + public Strategy getStrategy() + { + return strategy; + } + + public static CompressionParameters compute(int compressionLevel, int estimatedInputSize) + { + CompressionParameters defaultParameters = getDefaultParameters(compressionLevel, estimatedInputSize); + if (estimatedInputSize < 0) { + return defaultParameters; + } + + int targetLength = defaultParameters.targetLength; + int windowLog = defaultParameters.windowLog; + int chainLog = defaultParameters.chainLog; + int hashLog = defaultParameters.hashLog; + int searchLog = defaultParameters.searchLog; + int searchLength = defaultParameters.searchLength; + Strategy strategy = defaultParameters.strategy; + + if (compressionLevel < 0) { + targetLength = -compressionLevel; // acceleration factor + } + + long maxWindowResize = 1L << (MAX_WINDOW_LOG - 1); + if (estimatedInputSize < maxWindowResize) { + int hashSizeMin = 1 << MIN_HASH_LOG; + int inputSizeLog = (estimatedInputSize < hashSizeMin) ? MIN_HASH_LOG : highestBit(estimatedInputSize - 1) + 1; + if (windowLog > inputSizeLog) { + windowLog = inputSizeLog; + } + } + + if (hashLog > windowLog + 1) { + hashLog = windowLog + 1; + } + + int cycleLog = cycleLog(chainLog, strategy); + if (cycleLog > windowLog) { + chainLog -= (cycleLog - windowLog); + } + + if (windowLog < MIN_WINDOW_LOG) { + windowLog = MIN_WINDOW_LOG; + } + + return new CompressionParameters(windowLog, chainLog, hashLog, searchLog, searchLength, targetLength, strategy); + } + + private static CompressionParameters getDefaultParameters(int compressionLevel, long estimatedInputSize) + { + int table = 0; + + if (estimatedInputSize >= 0) { + if (estimatedInputSize <= 16 * 1024) { + table = 3; + } + else if (estimatedInputSize <= 128 * 1024) { + table = 2; + } + else if (estimatedInputSize <= 256 * 1024) { + table = 1; + } + } + + int row = DEFAULT_COMPRESSION_LEVEL; + + if (compressionLevel != 0) { // TODO: figure out better way to indicate default compression level + row = clamp(compressionLevel, 0, MAX_COMPRESSION_LEVEL); + } + + return DEFAULT_COMPRESSION_PARAMETERS[table][row]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java new file mode 100644 index 00000000..587ff5f7 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Constants.java @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +final class Constants +{ + public static final int SIZE_OF_BYTE = 1; + public static final int SIZE_OF_SHORT = 2; + public static final int SIZE_OF_INT = 4; + public static final int SIZE_OF_LONG = 8; + + public static final int MAGIC_NUMBER = 0xFD2FB528; + + public static final int MIN_WINDOW_LOG = 10; + public static final int MAX_WINDOW_LOG = 31; + + public static final int SIZE_OF_BLOCK_HEADER = 3; + + public static final int MIN_SEQUENCES_SIZE = 1; + public static final int MIN_BLOCK_SIZE = 1 // block type tag + + 1 // min size of raw or rle length header + + MIN_SEQUENCES_SIZE; + public static final int MAX_BLOCK_SIZE = 128 * 1024; + + public static final int REPEATED_OFFSET_COUNT = 3; + + // block types + public static final int RAW_BLOCK = 0; + public static final int RLE_BLOCK = 1; + public static final int COMPRESSED_BLOCK = 2; + + // sequence encoding types + public static final int SEQUENCE_ENCODING_BASIC = 0; + public static final int SEQUENCE_ENCODING_RLE = 1; + public static final int SEQUENCE_ENCODING_COMPRESSED = 2; + public static final int SEQUENCE_ENCODING_REPEAT = 3; + + public static final int MAX_LITERALS_LENGTH_SYMBOL = 35; + public static final int MAX_MATCH_LENGTH_SYMBOL = 52; + public static final int MAX_OFFSET_CODE_SYMBOL = 31; + public static final int DEFAULT_MAX_OFFSET_CODE_SYMBOL = 28; + + public static final int LITERAL_LENGTH_TABLE_LOG = 9; + public static final int MATCH_LENGTH_TABLE_LOG = 9; + public static final int OFFSET_TABLE_LOG = 8; + + // literal block types + public static final int RAW_LITERALS_BLOCK = 0; + public static final int RLE_LITERALS_BLOCK = 1; + public static final int COMPRESSED_LITERALS_BLOCK = 2; + public static final int TREELESS_LITERALS_BLOCK = 3; + + public static final int LONG_NUMBER_OF_SEQUENCES = 0x7F00; + + public static final int[] LITERALS_LENGTH_BITS = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 2, 2, 3, 3, + 4, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16}; + + public static final int[] MATCH_LENGTH_BITS = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 2, 2, 3, 3, + 4, 4, 5, 7, 8, 9, 10, 11, + 12, 13, 14, 15, 16}; + + private Constants() + { + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java new file mode 100644 index 00000000..1460ccfa --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/DoubleFastBlockCompressor.java @@ -0,0 +1,251 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; + +class DoubleFastBlockCompressor + implements BlockCompressor +{ + private static final int MIN_MATCH = 3; + private static final int SEARCH_STRENGTH = 8; + private static final int REP_MOVE = Constants.REPEATED_OFFSET_COUNT - 1; + + @Override + public int compressBlock(MemorySegment inputBase, final long inputAddress, int inputSize, SequenceStore output, BlockCompressionState state, RepeatedOffsets offsets, CompressionParameters parameters) + { + int matchSearchLength = Math.max(parameters.getSearchLength(), 4); + + final long baseAddress = state.getBaseAddress(); + final long windowBaseAddress = baseAddress + state.getWindowBaseOffset(); + + int[] longHashTable = state.hashTable; + int longHashBits = parameters.getHashLog(); + + int[] shortHashTable = state.chainTable; + int shortHashBits = parameters.getChainLog(); + + final long inputEnd = inputAddress + inputSize; + final long inputLimit = inputEnd - SIZE_OF_LONG; + + long input = inputAddress; + long anchor = inputAddress; + + int offset1 = offsets.getOffset0(); + int offset2 = offsets.getOffset1(); + + int savedOffset = 0; + + if (input - windowBaseAddress == 0) { + input++; + } + int maxRep = (int) (input - windowBaseAddress); + + if (offset2 > maxRep) { + savedOffset = offset2; + offset2 = 0; + } + + if (offset1 > maxRep) { + savedOffset = offset1; + offset1 = 0; + } + + while (input < inputLimit) { + int shortHash = hash(inputBase, input, shortHashBits, matchSearchLength); + long shortMatchAddress = baseAddress + shortHashTable[shortHash]; + + int longHash = hash8(inputBase.get(JAVA_LONG, input), longHashBits); + long longMatchAddress = baseAddress + longHashTable[longHash]; + + // update hash tables + int current = (int) (input - baseAddress); + longHashTable[longHash] = current; + shortHashTable[shortHash] = current; + + int matchLength; + int offset; + + if (offset1 > 0 && inputBase.get(JAVA_INT, input + 1 - offset1) == inputBase.get(JAVA_INT, input + 1)) { + matchLength = count(inputBase, input + 1 + SIZE_OF_INT, inputEnd, input + 1 + SIZE_OF_INT - offset1) + SIZE_OF_INT; + input++; + output.storeSequence(inputBase, anchor, (int) (input - anchor), 0, matchLength - MIN_MATCH); + } + else { + if (longMatchAddress > windowBaseAddress && inputBase.get(JAVA_LONG, longMatchAddress) == inputBase.get(JAVA_LONG, input)) { + matchLength = count(inputBase, input + SIZE_OF_LONG, inputEnd, longMatchAddress + SIZE_OF_LONG) + SIZE_OF_LONG; + offset = (int) (input - longMatchAddress); + while (input > anchor && longMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, longMatchAddress - 1)) { + input--; + longMatchAddress--; + matchLength++; + } + } + else { + if (shortMatchAddress > windowBaseAddress && inputBase.get(JAVA_INT, shortMatchAddress) == inputBase.get(JAVA_INT, input)) { + int nextOffsetHash = hash8(inputBase.get(JAVA_LONG, input + 1), longHashBits); + long nextOffsetMatchAddress = baseAddress + longHashTable[nextOffsetHash]; + longHashTable[nextOffsetHash] = current + 1; + + if (nextOffsetMatchAddress > windowBaseAddress && inputBase.get(JAVA_LONG, nextOffsetMatchAddress) == inputBase.get(JAVA_LONG, input + 1)) { + matchLength = count(inputBase, input + 1 + SIZE_OF_LONG, inputEnd, nextOffsetMatchAddress + SIZE_OF_LONG) + SIZE_OF_LONG; + input++; + offset = (int) (input - nextOffsetMatchAddress); + while (input > anchor && nextOffsetMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, nextOffsetMatchAddress - 1)) { + input--; + nextOffsetMatchAddress--; + matchLength++; + } + } + else { + matchLength = count(inputBase, input + SIZE_OF_INT, inputEnd, shortMatchAddress + SIZE_OF_INT) + SIZE_OF_INT; + offset = (int) (input - shortMatchAddress); + while (input > anchor && shortMatchAddress > windowBaseAddress && inputBase.get(JAVA_BYTE, input - 1) == inputBase.get(JAVA_BYTE, shortMatchAddress - 1)) { + input--; + shortMatchAddress--; + matchLength++; + } + } + } + else { + input += ((input - anchor) >> SEARCH_STRENGTH) + 1; + continue; + } + } + + offset2 = offset1; + offset1 = offset; + + output.storeSequence(inputBase, anchor, (int) (input - anchor), offset + REP_MOVE, matchLength - MIN_MATCH); + } + + input += matchLength; + anchor = input; + + if (input <= inputLimit) { + // Fill Table + longHashTable[hash8(inputBase.get(JAVA_LONG, baseAddress + current + 2), longHashBits)] = current + 2; + shortHashTable[hash(inputBase, baseAddress + current + 2, shortHashBits, matchSearchLength)] = current + 2; + + longHashTable[hash8(inputBase.get(JAVA_LONG, input - 2), longHashBits)] = (int) (input - 2 - baseAddress); + shortHashTable[hash(inputBase, input - 2, shortHashBits, matchSearchLength)] = (int) (input - 2 - baseAddress); + + while (input <= inputLimit && offset2 > 0 && inputBase.get(JAVA_INT, input) == inputBase.get(JAVA_INT, input - offset2)) { + int repetitionLength = count(inputBase, input + SIZE_OF_INT, inputEnd, input + SIZE_OF_INT - offset2) + SIZE_OF_INT; + + int temp = offset2; + offset2 = offset1; + offset1 = temp; + + shortHashTable[hash(inputBase, input, shortHashBits, matchSearchLength)] = (int) (input - baseAddress); + longHashTable[hash8(inputBase.get(JAVA_LONG, input), longHashBits)] = (int) (input - baseAddress); + + output.storeSequence(inputBase, anchor, 0, 0, repetitionLength - MIN_MATCH); + + input += repetitionLength; + anchor = input; + } + } + } + + offsets.saveOffset0(offset1 != 0 ? offset1 : savedOffset); + offsets.saveOffset1(offset2 != 0 ? offset2 : savedOffset); + + return (int) (inputEnd - anchor); + } + + /** + * matchAddress must be < inputAddress + */ + public static int count(MemorySegment inputBase, final long inputAddress, final long inputLimit, final long matchAddress) + { + long input = inputAddress; + long match = matchAddress; + + int remaining = (int) (inputLimit - inputAddress); + + int count = 0; + while (count < remaining - (SIZE_OF_LONG - 1)) { + long diff = inputBase.get(JAVA_LONG, match) ^ inputBase.get(JAVA_LONG, input); + if (diff != 0) { + return count + (Long.numberOfTrailingZeros(diff) >> 3); + } + + count += SIZE_OF_LONG; + input += SIZE_OF_LONG; + match += SIZE_OF_LONG; + } + + while (count < remaining && inputBase.get(JAVA_BYTE, match) == inputBase.get(JAVA_BYTE, input)) { + count++; + input++; + match++; + } + + return count; + } + + private static int hash(MemorySegment inputBase, long inputAddress, int bits, int matchSearchLength) + { + switch (matchSearchLength) { + case 8: + return hash8(inputBase.get(JAVA_LONG, inputAddress), bits); + case 7: + return hash7(inputBase.get(JAVA_LONG, inputAddress), bits); + case 6: + return hash6(inputBase.get(JAVA_LONG, inputAddress), bits); + case 5: + return hash5(inputBase.get(JAVA_LONG, inputAddress), bits); + default: + return hash4(inputBase.get(JAVA_INT, inputAddress), bits); + } + } + + private static final int PRIME_4_BYTES = 0x9E3779B1; + private static final long PRIME_5_BYTES = 0xCF1BBCDCBBL; + private static final long PRIME_6_BYTES = 0xCF1BBCDCBF9BL; + private static final long PRIME_7_BYTES = 0xCF1BBCDCBFA563L; + private static final long PRIME_8_BYTES = 0xCF1BBCDCB7A56463L; + + private static int hash4(int value, int bits) + { + return (value * PRIME_4_BYTES) >>> (Integer.SIZE - bits); + } + + private static int hash5(long value, int bits) + { + return (int) (((value << (Long.SIZE - 40)) * PRIME_5_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash6(long value, int bits) + { + return (int) (((value << (Long.SIZE - 48)) * PRIME_6_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash7(long value, int bits) + { + return (int) (((value << (Long.SIZE - 56)) * PRIME_7_BYTES) >>> (Long.SIZE - bits)); + } + + private static int hash8(long value, int bits) + { + return (int) ((value * PRIME_8_BYTES) >>> (Long.SIZE - bits)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java new file mode 100644 index 00000000..2aa025d5 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FfmUtil.java @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.IncompatibleJvmException; + +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.nio.ByteOrder; + +import static java.lang.String.format; + +final class FfmUtil +{ + public static final ValueLayout.OfByte JAVA_BYTE = ValueLayout.JAVA_BYTE; + public static final ValueLayout.OfShort JAVA_SHORT = ValueLayout.JAVA_SHORT_UNALIGNED; + public static final ValueLayout.OfInt JAVA_INT = ValueLayout.JAVA_INT_UNALIGNED; + public static final ValueLayout.OfLong JAVA_LONG = ValueLayout.JAVA_LONG_UNALIGNED; + + private FfmUtil() {} + + static { + ByteOrder order = ByteOrder.nativeOrder(); + if (!order.equals(ByteOrder.LITTLE_ENDIAN)) { + throw new IncompatibleJvmException(format("Zstandard requires a little endian platform (found %s)", order)); + } + } + + public static MemorySegment ofArray(byte[] array) + { + return MemorySegment.ofArray(array); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java new file mode 100644 index 00000000..44a3fb27 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FiniteStateEntropy.java @@ -0,0 +1,534 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBits; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +final class FiniteStateEntropy +{ + public static final int MAX_SYMBOL = 255; + public static final int MAX_TABLE_LOG = 12; + public static final int MIN_TABLE_LOG = 5; + + private static final int[] REST_TO_BEAT = new int[] {0, 473195, 504333, 520860, 550000, 700000, 750000, 830000}; + private static final short UNASSIGNED = -2; + + private FiniteStateEntropy() + { + } + + public static int decompress(FiniteStateEntropy.Table table, final MemorySegment inputBase, final long inputAddress, final long inputLimit, byte[] outputBuffer) + { + final MemorySegment outputBase = MemorySegment.ofArray(outputBuffer); + final long outputAddress = 0; + final long outputLimit = outputAddress + outputBuffer.length; + + long input = inputAddress; + long output = outputAddress; + + // initialize bit stream + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, input, inputLimit); + initializer.initialize(); + int bitsConsumed = initializer.getBitsConsumed(); + long currentAddress = initializer.getCurrentAddress(); + long bits = initializer.getBits(); + + // initialize first FSE stream + int state1 = (int) peekBits(bitsConsumed, bits, table.log2Size); + bitsConsumed += table.log2Size; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + + // initialize second FSE stream + int state2 = (int) peekBits(bitsConsumed, bits, table.log2Size); + bitsConsumed += table.log2Size; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + + byte[] symbols = table.symbol; + byte[] numbersOfBits = table.numberOfBits; + int[] newStates = table.newState; + + // decode 4 symbols per loop + while (output <= outputLimit - 4) { + int numberOfBits; + + outputBase.set(JAVA_BYTE, output, symbols[state1]); + numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 1, symbols[state2]); + numberOfBits = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 2, symbols[state1]); + numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + outputBase.set(JAVA_BYTE, output + 3, symbols[state2]); + numberOfBits = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + output += SIZE_OF_INT; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + } + + while (true) { + verify(output <= outputLimit - 2, input, "Output buffer is too small"); + outputBase.set(JAVA_BYTE, output++, symbols[state1]); + int numberOfBits = numbersOfBits[state1]; + state1 = (int) (newStates[state1] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + + if (loader.isOverflow()) { + outputBase.set(JAVA_BYTE, output++, symbols[state2]); + break; + } + + verify(output <= outputLimit - 2, input, "Output buffer is too small"); + outputBase.set(JAVA_BYTE, output++, symbols[state2]); + int numberOfBits1 = numbersOfBits[state2]; + state2 = (int) (newStates[state2] + peekBits(bitsConsumed, bits, numberOfBits1)); + bitsConsumed += numberOfBits1; + + loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + + if (loader.isOverflow()) { + outputBase.set(JAVA_BYTE, output++, symbols[state1]); + break; + } + } + + return (int) (output - outputAddress); + } + + public static int compress(MemorySegment outputBase, long outputAddress, int outputSize, byte[] input, int inputSize, FseCompressionTable table) + { + return compress(outputBase, outputAddress, outputSize, MemorySegment.ofArray(input), 0, inputSize, table); + } + + public static int compress(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, FseCompressionTable table) + { + checkArgument(outputSize >= SIZE_OF_LONG, "Output buffer too small"); + + final long start = inputAddress; + final long inputLimit = start + inputSize; + + long input = inputLimit; + + if (inputSize <= 2) { + return 0; + } + + BitOutputStream stream = new BitOutputStream(outputBase, outputAddress, outputSize); + + int state1; + int state2; + + if ((inputSize & 1) != 0) { + input--; + state1 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state2 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + stream.flush(); + } + else { + input--; + state2 = table.begin(inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.begin(inputBase.get(JAVA_BYTE, input)); + } + + // join to mod 4 + inputSize -= 2; + + if ((SIZE_OF_LONG * 8 > MAX_TABLE_LOG * 4 + 7) && (inputSize & 2) != 0) { /* test bit 2 */ + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + stream.flush(); + } + + // 2 or 4 encoding per loop + while (input > start) { + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + if (SIZE_OF_LONG * 8 < MAX_TABLE_LOG * 2 + 7) { + stream.flush(); + } + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + + if (SIZE_OF_LONG * 8 > MAX_TABLE_LOG * 4 + 7) { + input--; + state2 = table.encode(stream, state2, inputBase.get(JAVA_BYTE, input)); + + input--; + state1 = table.encode(stream, state1, inputBase.get(JAVA_BYTE, input)); + } + + stream.flush(); + } + + table.finish(stream, state2); + table.finish(stream, state1); + + return stream.close(); + } + + public static int optimalTableLog(int maxTableLog, int inputSize, int maxSymbol) + { + if (inputSize <= 1) { + throw new IllegalArgumentException(); // not supported. Use RLE instead + } + + int result = maxTableLog; + + result = Math.min(result, Util.highestBit((inputSize - 1)) - 2); // we may be able to reduce accuracy if input is small + + // Need a minimum to safely represent all symbol values + result = Math.max(result, Util.minTableLog(inputSize, maxSymbol)); + + result = Math.max(result, MIN_TABLE_LOG); + result = Math.min(result, MAX_TABLE_LOG); + + return result; + } + + public static int normalizeCounts(short[] normalizedCounts, int tableLog, int[] counts, int total, int maxSymbol) + { + checkArgument(tableLog >= MIN_TABLE_LOG, "Unsupported FSE table size"); + checkArgument(tableLog <= MAX_TABLE_LOG, "FSE table size too large"); + checkArgument(tableLog >= Util.minTableLog(total, maxSymbol), "FSE table size too small"); + + long scale = 62 - tableLog; + long step = (1L << 62) / total; + long vstep = 1L << (scale - 20); + + int stillToDistribute = 1 << tableLog; + + int largest = 0; + short largestProbability = 0; + int lowThreshold = total >>> tableLog; + + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + if (counts[symbol] == total) { + throw new IllegalArgumentException(); // TODO: should have been RLE-compressed by upper layers + } + if (counts[symbol] == 0) { + normalizedCounts[symbol] = 0; + continue; + } + if (counts[symbol] <= lowThreshold) { + normalizedCounts[symbol] = -1; + stillToDistribute--; + } + else { + short probability = (short) ((counts[symbol] * step) >>> scale); + if (probability < 8) { + long restToBeat = vstep * REST_TO_BEAT[probability]; + long delta = counts[symbol] * step - (((long) probability) << scale); + if (delta > restToBeat) { + probability++; + } + } + if (probability > largestProbability) { + largestProbability = probability; + largest = symbol; + } + normalizedCounts[symbol] = probability; + stillToDistribute -= probability; + } + } + + if (-stillToDistribute >= (normalizedCounts[largest] >>> 1)) { + // corner case. Need another normalization method + normalizeCounts2(normalizedCounts, tableLog, counts, total, maxSymbol); + } + else { + normalizedCounts[largest] += (short) stillToDistribute; + } + + return tableLog; + } + + private static int normalizeCounts2(short[] normalizedCounts, int tableLog, int[] counts, int total, int maxSymbol) + { + int distributed = 0; + + int lowThreshold = total >>> tableLog; + int lowOne = (total * 3) >>> (tableLog + 1); + + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] == 0) { + normalizedCounts[i] = 0; + } + else if (counts[i] <= lowThreshold) { + normalizedCounts[i] = -1; + distributed++; + total -= counts[i]; + } + else if (counts[i] <= lowOne) { + normalizedCounts[i] = 1; + distributed++; + total -= counts[i]; + } + else { + normalizedCounts[i] = UNASSIGNED; + } + } + + int normalizationFactor = 1 << tableLog; + int toDistribute = normalizationFactor - distributed; + + if ((total / toDistribute) > lowOne) { + lowOne = ((total * 3) / (toDistribute * 2)); + for (int i = 0; i <= maxSymbol; i++) { + if ((normalizedCounts[i] == UNASSIGNED) && (counts[i] <= lowOne)) { + normalizedCounts[i] = 1; + distributed++; + total -= counts[i]; + } + } + toDistribute = normalizationFactor - distributed; + } + + if (distributed == maxSymbol + 1) { + int maxValue = 0; + int maxCount = 0; + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] > maxCount) { + maxValue = i; + maxCount = counts[i]; + } + } + normalizedCounts[maxValue] += (short) toDistribute; + return 0; + } + + if (total == 0) { + for (int i = 0; toDistribute > 0; i = (i + 1) % (maxSymbol + 1)) { + if (normalizedCounts[i] > 0) { + toDistribute--; + normalizedCounts[i]++; + } + } + return 0; + } + + long vStepLog = 62 - tableLog; + long mid = (1L << (vStepLog - 1)) - 1; + long rStep = (((1L << vStepLog) * toDistribute) + mid) / total; + long tmpTotal = mid; + for (int i = 0; i <= maxSymbol; i++) { + if (normalizedCounts[i] == UNASSIGNED) { + long end = tmpTotal + (counts[i] * rStep); + int sStart = (int) (tmpTotal >>> vStepLog); + int sEnd = (int) (end >>> vStepLog); + int weight = sEnd - sStart; + + if (weight < 1) { + throw new AssertionError(); + } + normalizedCounts[i] = (short) weight; + tmpTotal = end; + } + } + + return 0; + } + + public static int writeNormalizedCounts(MemorySegment outputBase, long outputAddress, int outputSize, short[] normalizedCounts, int maxSymbol, int tableLog) + { + checkArgument(tableLog <= MAX_TABLE_LOG, "FSE table too large"); + checkArgument(tableLog >= MIN_TABLE_LOG, "FSE table too small"); + + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int tableSize = 1 << tableLog; + + int bitCount = 0; + + // encode table size + int bitStream = (tableLog - MIN_TABLE_LOG); + bitCount += 4; + + int remaining = tableSize + 1; // +1 for extra accuracy + int threshold = tableSize; + int tableBitCount = tableLog + 1; + + int symbol = 0; + + boolean previousIs0 = false; + while (remaining > 1) { + if (previousIs0) { + int start = symbol; + + // find run of symbols with count 0 + while (normalizedCounts[symbol] == 0) { + symbol++; + } + + while (symbol >= start + 24) { + start += 24; + bitStream |= (0b11_11_11_11_11_11_11_11 << bitCount); + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + } + + while (symbol >= start + 3) { + start += 3; + bitStream |= 0b11 << bitCount; + bitCount += 2; + } + + bitStream |= (symbol - start) << bitCount; + bitCount += 2; + + if (bitCount > 16) { + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + bitCount -= Short.SIZE; + } + } + + int count = normalizedCounts[symbol++]; + int max = (2 * threshold - 1) - remaining; + remaining -= count < 0 ? -count : count; + count++; /* +1 for extra accuracy */ + if (count >= threshold) { + count += max; + } + bitStream |= count << bitCount; + bitCount += tableBitCount; + bitCount -= (count < max ? 1 : 0); + previousIs0 = (count == 1); + + if (remaining < 1) { + throw new AssertionError(); + } + + while (remaining < threshold) { + tableBitCount--; + threshold >>= 1; + } + + if (bitCount > 16) { + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += Constants.SIZE_OF_SHORT; + + bitStream >>>= Short.SIZE; + bitCount -= Short.SIZE; + } + } + + // flush remaining bitstream + checkArgument(output + Constants.SIZE_OF_SHORT <= outputLimit, "Output buffer too small"); + outputBase.set(JAVA_SHORT, output, (short) bitStream); + output += (bitCount + 7) / 8; + + checkArgument(symbol <= maxSymbol + 1, "Error"); // TODO + + return (int) (output - outputAddress); + } + + public static final class Table + { + int log2Size; + final int[] newState; + final byte[] symbol; + final byte[] numberOfBits; + + public Table(int log2Capacity) + { + int capacity = 1 << log2Capacity; + newState = new int[capacity]; + symbol = new byte[capacity]; + numberOfBits = new byte[capacity]; + } + + public Table(int log2Size, int[] newState, byte[] symbol, byte[] numberOfBits) + { + int size = 1 << log2Size; + if (newState.length != size || symbol.length != size || numberOfBits.length != size) { + throw new IllegalArgumentException("Expected arrays to match provided size"); + } + + this.log2Size = log2Size; + this.newState = newState; + this.symbol = symbol; + this.numberOfBits = numberOfBits; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java new file mode 100644 index 00000000..504d04d8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FrameHeader.java @@ -0,0 +1,86 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Objects; +import java.util.StringJoiner; + +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.min; +import static java.lang.Math.toIntExact; + +class FrameHeader +{ + final long headerSize; + final int windowSize; + final long contentSize; + final long dictionaryId; + final boolean hasChecksum; + + public FrameHeader(long headerSize, int windowSize, long contentSize, long dictionaryId, boolean hasChecksum) + { + checkState(windowSize >= 0 || contentSize >= 0, "Invalid frame header: contentSize or windowSize must be set"); + this.headerSize = headerSize; + this.windowSize = windowSize; + this.contentSize = contentSize; + this.dictionaryId = dictionaryId; + this.hasChecksum = hasChecksum; + } + + public int computeRequiredOutputBufferLookBackSize() + { + if (contentSize < 0) { + return windowSize; + } + if (windowSize < 0) { + return toIntExact(contentSize); + } + return toIntExact(min(windowSize, contentSize)); + } + + @Override + public boolean equals(Object o) + { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FrameHeader that = (FrameHeader) o; + return headerSize == that.headerSize && + windowSize == that.windowSize && + contentSize == that.contentSize && + dictionaryId == that.dictionaryId && + hasChecksum == that.hasChecksum; + } + + @Override + public int hashCode() + { + return Objects.hash(headerSize, windowSize, contentSize, dictionaryId, hasChecksum); + } + + @Override + public String toString() + { + return new StringJoiner(", ", FrameHeader.class.getSimpleName() + "[", "]") + .add("headerSize=" + headerSize) + .add("windowSize=" + windowSize) + .add("contentSize=" + contentSize) + .add("dictionaryId=" + dictionaryId) + .add("hasChecksum=" + hasChecksum) + .toString(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java new file mode 100644 index 00000000..66c9b5dc --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FseCompressionTable.java @@ -0,0 +1,158 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MAX_SYMBOL; + +class FseCompressionTable +{ + private final short[] nextState; + private final int[] deltaNumberOfBits; + private final int[] deltaFindState; + + private int log2Size; + + public FseCompressionTable(int maxTableLog, int maxSymbol) + { + nextState = new short[1 << maxTableLog]; + deltaNumberOfBits = new int[maxSymbol + 1]; + deltaFindState = new int[maxSymbol + 1]; + } + + public static FseCompressionTable newInstance(short[] normalizedCounts, int maxSymbol, int tableLog) + { + FseCompressionTable result = new FseCompressionTable(tableLog, maxSymbol); + result.initialize(normalizedCounts, maxSymbol, tableLog); + + return result; + } + + public void initializeRleTable(int symbol) + { + log2Size = 0; + + nextState[0] = 0; + nextState[1] = 0; + + deltaFindState[symbol] = 0; + deltaNumberOfBits[symbol] = 0; + } + + public void initialize(short[] normalizedCounts, int maxSymbol, int tableLog) + { + int tableSize = 1 << tableLog; + + byte[] table = new byte[tableSize]; // TODO: allocate in workspace + int highThreshold = tableSize - 1; + + // TODO: make sure FseCompressionTable has enough size + log2Size = tableLog; + + // For explanations on how to distribute symbol values over the table: + // http://fastcompression.blogspot.fr/2014/02/fse-distributing-symbol-values.html + + // symbol start positions + int[] cumulative = new int[MAX_SYMBOL + 2]; // TODO: allocate in workspace + cumulative[0] = 0; + for (int i = 1; i <= maxSymbol + 1; i++) { + if (normalizedCounts[i - 1] == -1) { // Low probability symbol + cumulative[i] = cumulative[i - 1] + 1; + table[highThreshold--] = (byte) (i - 1); + } + else { + cumulative[i] = cumulative[i - 1] + normalizedCounts[i - 1]; + } + } + cumulative[maxSymbol + 1] = tableSize + 1; + + // Spread symbols + int position = spreadSymbols(normalizedCounts, maxSymbol, tableSize, highThreshold, table); + + if (position != 0) { + throw new AssertionError("Spread symbols failed"); + } + + // Build table + for (int i = 0; i < tableSize; i++) { + byte symbol = table[i]; + nextState[cumulative[symbol]++] = (short) (tableSize + i); /* TableU16 : sorted by symbol order; gives next state value */ + } + + // Build symbol transformation table + int total = 0; + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + switch (normalizedCounts[symbol]) { + case 0: + deltaNumberOfBits[symbol] = ((tableLog + 1) << 16) - tableSize; + break; + case -1: + case 1: + deltaNumberOfBits[symbol] = (tableLog << 16) - tableSize; + deltaFindState[symbol] = total - 1; + total++; + break; + default: + int maxBitsOut = tableLog - Util.highestBit(normalizedCounts[symbol] - 1); + int minStatePlus = normalizedCounts[symbol] << maxBitsOut; + deltaNumberOfBits[symbol] = (maxBitsOut << 16) - minStatePlus; + deltaFindState[symbol] = total - normalizedCounts[symbol]; + total += normalizedCounts[symbol]; + break; + } + } + } + + public int begin(byte symbol) + { + int outputBits = (deltaNumberOfBits[symbol] + (1 << 15)) >>> 16; + int base = ((outputBits << 16) - deltaNumberOfBits[symbol]) >>> outputBits; + return nextState[base + deltaFindState[symbol]]; + } + + public int encode(BitOutputStream stream, int state, int symbol) + { + int outputBits = (state + deltaNumberOfBits[symbol]) >>> 16; + stream.addBits(state, outputBits); + return nextState[(state >>> outputBits) + deltaFindState[symbol]]; + } + + public void finish(BitOutputStream stream, int state) + { + stream.addBits(state, log2Size); + stream.flush(); + } + + private static int calculateStep(int tableSize) + { + return (tableSize >>> 1) + (tableSize >>> 3) + 3; + } + + public static int spreadSymbols(short[] normalizedCounters, int maxSymbolValue, int tableSize, int highThreshold, byte[] symbols) + { + int mask = tableSize - 1; + int step = calculateStep(tableSize); + + int position = 0; + for (byte symbol = 0; symbol <= maxSymbolValue; symbol++) { + for (int i = 0; i < normalizedCounters[symbol]; i++) { + symbols[position] = symbol; + do { + position = (position + step) & mask; + } + while (position > highThreshold); + } + } + return position; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java b/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java new file mode 100644 index 00000000..c2427cc0 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/FseTableReader.java @@ -0,0 +1,171 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.MIN_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.highestBit; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +class FseTableReader +{ + private final short[] nextSymbol = new short[MAX_SYMBOL + 1]; + private final short[] normalizedCounters = new short[MAX_SYMBOL + 1]; + + public int readFseTable(FiniteStateEntropy.Table table, MemorySegment inputBase, long inputAddress, long inputLimit, int maxSymbol, int maxTableLog) + { + // read table headers + long input = inputAddress; + verify(inputLimit - inputAddress >= 4, input, "Not enough input bytes"); + + int threshold; + int symbolNumber = 0; + boolean previousIsZero = false; + + int bitStream = inputBase.get(JAVA_INT, input); + + int tableLog = (bitStream & 0xF) + MIN_TABLE_LOG; + + int numberOfBits = tableLog + 1; + bitStream >>>= 4; + int bitCount = 4; + + verify(tableLog <= maxTableLog, input, "FSE table size exceeds maximum allowed size"); + + int remaining = (1 << tableLog) + 1; + threshold = 1 << tableLog; + + while (remaining > 1 && symbolNumber <= maxSymbol) { + if (previousIsZero) { + int n0 = symbolNumber; + while ((bitStream & 0xFFFF) == 0xFFFF) { + n0 += 24; + if (input < inputLimit - 5) { + input += 2; + bitStream = (inputBase.get(JAVA_INT, input) >>> bitCount); + } + else { + // end of bit stream + bitStream >>>= 16; + bitCount += 16; + } + } + while ((bitStream & 3) == 3) { + n0 += 3; + bitStream >>>= 2; + bitCount += 2; + } + n0 += bitStream & 3; + bitCount += 2; + + verify(n0 <= maxSymbol, input, "Symbol larger than max value"); + + while (symbolNumber < n0) { + normalizedCounters[symbolNumber++] = 0; + } + if ((input <= inputLimit - 7) || (input + (bitCount >>> 3) <= inputLimit - 4)) { + input += bitCount >>> 3; + bitCount &= 7; + bitStream = inputBase.get(JAVA_INT, input) >>> bitCount; + } + else { + bitStream >>>= 2; + } + } + + short max = (short) ((2 * threshold - 1) - remaining); + short count; + + if ((bitStream & (threshold - 1)) < max) { + count = (short) (bitStream & (threshold - 1)); + bitCount += numberOfBits - 1; + } + else { + count = (short) (bitStream & (2 * threshold - 1)); + if (count >= threshold) { + count -= max; + } + bitCount += numberOfBits; + } + count--; // extra accuracy + + remaining -= Math.abs(count); + normalizedCounters[symbolNumber++] = count; + previousIsZero = count == 0; + while (remaining < threshold) { + numberOfBits--; + threshold >>>= 1; + } + + if ((input <= inputLimit - 7) || (input + (bitCount >> 3) <= inputLimit - 4)) { + input += bitCount >>> 3; + bitCount &= 7; + } + else { + bitCount -= (int) (8 * (inputLimit - 4 - input)); + input = inputLimit - 4; + } + bitStream = inputBase.get(JAVA_INT, input) >>> (bitCount & 31); + } + + verify(remaining == 1 && bitCount <= 32, input, "Input is corrupted"); + + maxSymbol = symbolNumber - 1; + verify(maxSymbol <= MAX_SYMBOL, input, "Max symbol value too large (too many symbols for FSE)"); + + input += (bitCount + 7) >> 3; + + // populate decoding table + int symbolCount = maxSymbol + 1; + int tableSize = 1 << tableLog; + int highThreshold = tableSize - 1; + + table.log2Size = tableLog; + + for (byte symbol = 0; symbol < symbolCount; symbol++) { + if (normalizedCounters[symbol] == -1) { + table.symbol[highThreshold--] = symbol; + nextSymbol[symbol] = 1; + } + else { + nextSymbol[symbol] = normalizedCounters[symbol]; + } + } + + int position = FseCompressionTable.spreadSymbols(normalizedCounters, maxSymbol, tableSize, highThreshold, table.symbol); + + // position must reach all cells once, otherwise normalizedCounter is incorrect + verify(position == 0, input, "Input is corrupted"); + + for (int i = 0; i < tableSize; i++) { + byte symbol = table.symbol[i]; + short nextState = nextSymbol[symbol]++; + table.numberOfBits[i] = (byte) (tableLog - highestBit(nextState)); + table.newState[i] = (short) ((nextState << table.numberOfBits[i]) - tableSize); + } + + return (int) (input - inputAddress); + } + + public static void initializeRleTable(FiniteStateEntropy.Table table, byte value) + { + table.log2Size = 0; + table.symbol[0] = value; + table.newState[0] = 0; + table.numberOfBits[0] = 0; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java new file mode 100644 index 00000000..3363f93f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Histogram.java @@ -0,0 +1,65 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; + +final class Histogram +{ + private Histogram() + { + } + + // TODO: count parallel heuristic for large inputs + private static void count(MemorySegment inputBase, long inputAddress, int inputSize, int[] counts) + { + long input = inputAddress; + + Arrays.fill(counts, 0); + + for (int i = 0; i < inputSize; i++) { + int symbol = inputBase.get(JAVA_BYTE, input) & 0xFF; + input++; + counts[symbol]++; + } + } + + public static int findLargestCount(int[] counts, int maxSymbol) + { + int max = 0; + for (int i = 0; i <= maxSymbol; i++) { + if (counts[i] > max) { + max = counts[i]; + } + } + + return max; + } + + public static int findMaxSymbol(int[] counts, int maxSymbol) + { + while (counts[maxSymbol] == 0) { + maxSymbol--; + } + return maxSymbol; + } + + public static void count(byte[] input, int length, int[] counts) + { + count(MemorySegment.ofArray(input), 0, length, counts); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java new file mode 100644 index 00000000..88d2d3a9 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Huffman.java @@ -0,0 +1,327 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.isEndOfStream; +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBitsFast; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.isPowerOf2; +import static io.airlift.compress.v3.zstdFFM.Util.verify; + +class Huffman +{ + public static final int MAX_SYMBOL = 255; + public static final int MAX_SYMBOL_COUNT = MAX_SYMBOL + 1; + + public static final int MAX_TABLE_LOG = 12; + public static final int MIN_TABLE_LOG = 5; + public static final int MAX_FSE_TABLE_LOG = 6; + + // stats + private final byte[] weights = new byte[MAX_SYMBOL + 1]; + private final int[] ranks = new int[MAX_TABLE_LOG + 1]; + + // table + private int tableLog = -1; + private final byte[] symbols = new byte[1 << MAX_TABLE_LOG]; + private final byte[] numbersOfBits = new byte[1 << MAX_TABLE_LOG]; + + private final FseTableReader reader = new FseTableReader(); + private final FiniteStateEntropy.Table fseTable = new FiniteStateEntropy.Table(MAX_FSE_TABLE_LOG); + + public boolean isLoaded() + { + return tableLog != -1; + } + + public int readTable(final MemorySegment inputBase, final long inputAddress, final int size) + { + Arrays.fill(ranks, 0); + long input = inputAddress; + + // read table header + verify(size > 0, input, "Not enough input bytes"); + int inputSize = inputBase.get(JAVA_BYTE, input++) & 0xFF; + + int outputSize; + if (inputSize >= 128) { + outputSize = inputSize - 127; + inputSize = ((outputSize + 1) / 2); + + verify(inputSize + 1 <= size, input, "Not enough input bytes"); + verify(outputSize <= MAX_SYMBOL + 1, input, "Input is corrupted"); + + for (int i = 0; i < outputSize; i += 2) { + int value = inputBase.get(JAVA_BYTE, input + i / 2) & 0xFF; + weights[i] = (byte) (value >>> 4); + weights[i + 1] = (byte) (value & 0b1111); + } + } + else { + verify(inputSize + 1 <= size, input, "Not enough input bytes"); + + long inputLimit = input + inputSize; + input += reader.readFseTable(fseTable, inputBase, input, inputLimit, FiniteStateEntropy.MAX_SYMBOL, MAX_FSE_TABLE_LOG); + outputSize = FiniteStateEntropy.decompress(fseTable, inputBase, input, inputLimit, weights); + } + + int totalWeight = 0; + for (int i = 0; i < outputSize; i++) { + ranks[weights[i]]++; + totalWeight += (1 << weights[i]) >> 1; + } + verify(totalWeight != 0, input, "Input is corrupted"); + + tableLog = Util.highestBit(totalWeight) + 1; + verify(tableLog <= MAX_TABLE_LOG, input, "Input is corrupted"); + + int total = 1 << tableLog; + int rest = total - totalWeight; + verify(isPowerOf2(rest), input, "Input is corrupted"); + + int lastWeight = Util.highestBit(rest) + 1; + + weights[outputSize] = (byte) lastWeight; + ranks[lastWeight]++; + + int numberOfSymbols = outputSize + 1; + + // populate table + int nextRankStart = 0; + for (int i = 1; i < tableLog + 1; ++i) { + int current = nextRankStart; + nextRankStart += ranks[i] << (i - 1); + ranks[i] = current; + } + + for (int n = 0; n < numberOfSymbols; n++) { + int weight = weights[n]; + int length = (1 << weight) >> 1; + + byte symbol = (byte) n; + byte numberOfBits = (byte) (tableLog + 1 - weight); + for (int i = ranks[weight]; i < ranks[weight] + length; i++) { + symbols[i] = symbol; + numbersOfBits[i] = numberOfBits; + } + ranks[weight] += length; + } + + verify(ranks[1] >= 2 && (ranks[1] & 1) == 0, input, "Input is corrupted"); + + return inputSize + 1; + } + + public void decodeSingleStream(final MemorySegment inputBase, final long inputAddress, final long inputLimit, final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, inputAddress, inputLimit); + initializer.initialize(); + + long bits = initializer.getBits(); + int bitsConsumed = initializer.getBitsConsumed(); + long currentAddress = initializer.getCurrentAddress(); + + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + // 4 symbols at a time + long output = outputAddress; + long fastOutputLimit = outputLimit - 4; + while (output < fastOutputLimit) { + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, inputAddress, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bits = loader.getBits(); + bitsConsumed = loader.getBitsConsumed(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + bitsConsumed = decodeSymbol(outputBase, output, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 1, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 2, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + bitsConsumed = decodeSymbol(outputBase, output + 3, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + output += SIZE_OF_INT; + } + + decodeTail(inputBase, inputAddress, currentAddress, bitsConsumed, bits, outputBase, output, outputLimit); + } + + public void decode4Streams(final MemorySegment inputBase, final long inputAddress, final long inputLimit, final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + verify(inputLimit - inputAddress >= 10, inputAddress, "Input is corrupted"); // jump table + 1 byte per stream + + long start1 = inputAddress + 3 * SIZE_OF_SHORT; // for the shorts we read below + long start2 = start1 + (inputBase.get(JAVA_SHORT, inputAddress) & 0xFFFF); + long start3 = start2 + (inputBase.get(JAVA_SHORT, inputAddress + 2) & 0xFFFF); + long start4 = start3 + (inputBase.get(JAVA_SHORT, inputAddress + 4) & 0xFFFF); + + verify(start2 < start3 && start3 < start4 && start4 < inputLimit, inputAddress, "Input is corrupted"); + + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, start1, start2); + initializer.initialize(); + int stream1bitsConsumed = initializer.getBitsConsumed(); + long stream1currentAddress = initializer.getCurrentAddress(); + long stream1bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start2, start3); + initializer.initialize(); + int stream2bitsConsumed = initializer.getBitsConsumed(); + long stream2currentAddress = initializer.getCurrentAddress(); + long stream2bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start3, start4); + initializer.initialize(); + int stream3bitsConsumed = initializer.getBitsConsumed(); + long stream3currentAddress = initializer.getCurrentAddress(); + long stream3bits = initializer.getBits(); + + initializer = new BitInputStream.Initializer(inputBase, start4, inputLimit); + initializer.initialize(); + int stream4bitsConsumed = initializer.getBitsConsumed(); + long stream4currentAddress = initializer.getCurrentAddress(); + long stream4bits = initializer.getBits(); + + int segmentSize = (int) ((outputLimit - outputAddress + 3) / 4); + + long outputStart2 = outputAddress + segmentSize; + long outputStart3 = outputStart2 + segmentSize; + long outputStart4 = outputStart3 + segmentSize; + + long output1 = outputAddress; + long output2 = outputStart2; + long output3 = outputStart3; + long output4 = outputStart4; + + long fastOutputLimit = outputLimit - 7; + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + while (output4 < fastOutputLimit) { + stream1bitsConsumed = decodeSymbol(outputBase, output1, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 1, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 1, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 1, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 1, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 2, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 2, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 2, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 2, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + stream1bitsConsumed = decodeSymbol(outputBase, output1 + 3, stream1bits, stream1bitsConsumed, tableLog, numbersOfBits, symbols); + stream2bitsConsumed = decodeSymbol(outputBase, output2 + 3, stream2bits, stream2bitsConsumed, tableLog, numbersOfBits, symbols); + stream3bitsConsumed = decodeSymbol(outputBase, output3 + 3, stream3bits, stream3bitsConsumed, tableLog, numbersOfBits, symbols); + stream4bitsConsumed = decodeSymbol(outputBase, output4 + 3, stream4bits, stream4bitsConsumed, tableLog, numbersOfBits, symbols); + + output1 += SIZE_OF_INT; + output2 += SIZE_OF_INT; + output3 += SIZE_OF_INT; + output4 += SIZE_OF_INT; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, start1, stream1currentAddress, stream1bits, stream1bitsConsumed); + boolean done = loader.load(); + stream1bitsConsumed = loader.getBitsConsumed(); + stream1bits = loader.getBits(); + stream1currentAddress = loader.getCurrentAddress(); + + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start2, stream2currentAddress, stream2bits, stream2bitsConsumed); + done = loader.load(); + stream2bitsConsumed = loader.getBitsConsumed(); + stream2bits = loader.getBits(); + stream2currentAddress = loader.getCurrentAddress(); + + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start3, stream3currentAddress, stream3bits, stream3bitsConsumed); + done = loader.load(); + stream3bitsConsumed = loader.getBitsConsumed(); + stream3bits = loader.getBits(); + stream3currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + loader = new BitInputStream.Loader(inputBase, start4, stream4currentAddress, stream4bits, stream4bitsConsumed); + done = loader.load(); + stream4bitsConsumed = loader.getBitsConsumed(); + stream4bits = loader.getBits(); + stream4currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + } + + verify(output1 <= outputStart2 && output2 <= outputStart3 && output3 <= outputStart4, inputAddress, "Input is corrupted"); + + /// finish streams one by one + decodeTail(inputBase, start1, stream1currentAddress, stream1bitsConsumed, stream1bits, outputBase, output1, outputStart2); + decodeTail(inputBase, start2, stream2currentAddress, stream2bitsConsumed, stream2bits, outputBase, output2, outputStart3); + decodeTail(inputBase, start3, stream3currentAddress, stream3bitsConsumed, stream3bits, outputBase, output3, outputStart4); + decodeTail(inputBase, start4, stream4currentAddress, stream4bitsConsumed, stream4bits, outputBase, output4, outputLimit); + } + + private void decodeTail(final MemorySegment inputBase, final long startAddress, long currentAddress, int bitsConsumed, long bits, final MemorySegment outputBase, long outputAddress, final long outputLimit) + { + int tableLog = this.tableLog; + byte[] numbersOfBits = this.numbersOfBits; + byte[] symbols = this.symbols; + + // closer to the end + while (outputAddress < outputLimit) { + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, startAddress, currentAddress, bits, bitsConsumed); + boolean done = loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (done) { + break; + } + + bitsConsumed = decodeSymbol(outputBase, outputAddress++, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + } + + // not more data in bit stream, so no need to reload + while (outputAddress < outputLimit) { + bitsConsumed = decodeSymbol(outputBase, outputAddress++, bits, bitsConsumed, tableLog, numbersOfBits, symbols); + } + + verify(isEndOfStream(startAddress, currentAddress, bitsConsumed), startAddress, "Bit stream is not fully consumed"); + } + + private static int decodeSymbol(MemorySegment outputBase, long outputAddress, long bitContainer, int bitsConsumed, int tableLog, byte[] numbersOfBits, byte[] symbols) + { + int value = (int) peekBitsFast(bitsConsumed, bitContainer, tableLog); + outputBase.set(JAVA_BYTE, outputAddress, symbols[value]); + return bitsConsumed + numbersOfBits[value]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java new file mode 100644 index 00000000..5bb70933 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionContext.java @@ -0,0 +1,61 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +class HuffmanCompressionContext +{ + private final HuffmanTableWriterWorkspace tableWriterWorkspace = new HuffmanTableWriterWorkspace(); + private final HuffmanCompressionTableWorkspace compressionTableWorkspace = new HuffmanCompressionTableWorkspace(); + + private HuffmanCompressionTable previousTable = new HuffmanCompressionTable(Huffman.MAX_SYMBOL_COUNT); + private HuffmanCompressionTable temporaryTable = new HuffmanCompressionTable(Huffman.MAX_SYMBOL_COUNT); + + private HuffmanCompressionTable previousCandidate = previousTable; + private HuffmanCompressionTable temporaryCandidate = temporaryTable; + + public HuffmanCompressionTable getPreviousTable() + { + return previousTable; + } + + public HuffmanCompressionTable borrowTemporaryTable() + { + previousCandidate = temporaryTable; + temporaryCandidate = previousTable; + + return temporaryTable; + } + + public void discardTemporaryTable() + { + previousCandidate = previousTable; + temporaryCandidate = temporaryTable; + } + + public void saveChanges() + { + temporaryTable = temporaryCandidate; + previousTable = previousCandidate; + } + + public HuffmanCompressionTableWorkspace getCompressionTableWorkspace() + { + return compressionTableWorkspace; + } + + public HuffmanTableWriterWorkspace getTableWriterWorkspace() + { + return tableWriterWorkspace; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java new file mode 100644 index 00000000..fe67d208 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTable.java @@ -0,0 +1,396 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_FSE_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL_COUNT; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MIN_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.minTableLog; + +final class HuffmanCompressionTable +{ + private final short[] values; + private final byte[] numberOfBits; + + private int maxSymbol; + private int maxNumberOfBits; + + public HuffmanCompressionTable(int capacity) + { + this.values = new short[capacity]; + this.numberOfBits = new byte[capacity]; + } + + public static int optimalNumberOfBits(int maxNumberOfBits, int inputSize, int maxSymbol) + { + if (inputSize <= 1) { + throw new IllegalArgumentException(); // not supported. Use RLE instead + } + + int result = maxNumberOfBits; + + result = Math.min(result, Util.highestBit((inputSize - 1)) - 1); + + result = Math.max(result, minTableLog(inputSize, maxSymbol)); + + result = Math.max(result, MIN_TABLE_LOG); + result = Math.min(result, MAX_TABLE_LOG); + + return result; + } + + public void initialize(int[] counts, int maxSymbol, int maxNumberOfBits, HuffmanCompressionTableWorkspace workspace) + { + checkArgument(maxSymbol <= MAX_SYMBOL, "Max symbol value too large"); + + workspace.reset(); + + NodeTable nodeTable = workspace.nodeTable; + nodeTable.reset(); + + int lastNonZero = buildTree(counts, maxSymbol, nodeTable); + + // enforce max table log + maxNumberOfBits = setMaxHeight(nodeTable, lastNonZero, maxNumberOfBits, workspace); + checkArgument(maxNumberOfBits <= MAX_TABLE_LOG, "Max number of bits larger than max table size"); + + // populate table + int symbolCount = maxSymbol + 1; + for (int node = 0; node < symbolCount; node++) { + int symbol = nodeTable.symbols[node]; + numberOfBits[symbol] = nodeTable.numberOfBits[node]; + } + + short[] entriesPerRank = workspace.entriesPerRank; + short[] valuesPerRank = workspace.valuesPerRank; + + for (int n = 0; n <= lastNonZero; n++) { + entriesPerRank[nodeTable.numberOfBits[n]]++; + } + + // determine starting value per rank + short startingValue = 0; + for (int rank = maxNumberOfBits; rank > 0; rank--) { + valuesPerRank[rank] = startingValue; + startingValue += entriesPerRank[rank]; + startingValue >>>= 1; + } + + for (int n = 0; n <= maxSymbol; n++) { + values[n] = valuesPerRank[numberOfBits[n]]++; + } + + this.maxSymbol = maxSymbol; + this.maxNumberOfBits = maxNumberOfBits; + } + + private int buildTree(int[] counts, int maxSymbol, NodeTable nodeTable) + { + short current = 0; + + for (int symbol = 0; symbol <= maxSymbol; symbol++) { + int count = counts[symbol]; + + int position = current; + while (position > 1 && count > nodeTable.count[position - 1]) { + nodeTable.copyNode(position - 1, position); + position--; + } + + nodeTable.count[position] = count; + nodeTable.symbols[position] = symbol; + + current++; + } + + int lastNonZero = maxSymbol; + while (nodeTable.count[lastNonZero] == 0) { + lastNonZero--; + } + + short nonLeafStart = MAX_SYMBOL_COUNT; + current = nonLeafStart; + + int currentLeaf = lastNonZero; + + int currentNonLeaf = current; + nodeTable.count[current] = nodeTable.count[currentLeaf] + nodeTable.count[currentLeaf - 1]; + nodeTable.parents[currentLeaf] = current; + nodeTable.parents[currentLeaf - 1] = current; + current++; + currentLeaf -= 2; + + int root = MAX_SYMBOL_COUNT + lastNonZero - 1; + + for (int n = current; n <= root; n++) { + nodeTable.count[n] = 1 << 30; + } + + while (current <= root) { + int child1; + if (currentLeaf >= 0 && nodeTable.count[currentLeaf] < nodeTable.count[currentNonLeaf]) { + child1 = currentLeaf--; + } + else { + child1 = currentNonLeaf++; + } + + int child2; + if (currentLeaf >= 0 && nodeTable.count[currentLeaf] < nodeTable.count[currentNonLeaf]) { + child2 = currentLeaf--; + } + else { + child2 = currentNonLeaf++; + } + + nodeTable.count[current] = nodeTable.count[child1] + nodeTable.count[child2]; + nodeTable.parents[child1] = current; + nodeTable.parents[child2] = current; + current++; + } + + nodeTable.numberOfBits[root] = 0; + for (int n = root - 1; n >= nonLeafStart; n--) { + short parent = nodeTable.parents[n]; + nodeTable.numberOfBits[n] = (byte) (nodeTable.numberOfBits[parent] + 1); + } + + for (int n = 0; n <= lastNonZero; n++) { + short parent = nodeTable.parents[n]; + nodeTable.numberOfBits[n] = (byte) (nodeTable.numberOfBits[parent] + 1); + } + + return lastNonZero; + } + + public void encodeSymbol(BitOutputStream output, int symbol) + { + output.addBitsFast(values[symbol], numberOfBits[symbol]); + } + + public int write(MemorySegment outputBase, long outputAddress, int outputSize, HuffmanTableWriterWorkspace workspace) + { + byte[] weights = workspace.weights; + + long output = outputAddress; + + int maxNumberOfBits = this.maxNumberOfBits; + int maxSymbol = this.maxSymbol; + + for (int symbol = 0; symbol < maxSymbol; symbol++) { + int bits = numberOfBits[symbol]; + + if (bits == 0) { + weights[symbol] = 0; + } + else { + weights[symbol] = (byte) (maxNumberOfBits + 1 - bits); + } + } + + int size = compressWeights(outputBase, output + 1, outputSize - 1, weights, maxSymbol, workspace); + + if (maxSymbol > 127 && size > 127) { + throw new AssertionError(); + } + + if (size != 0 && size != 1 && size < maxSymbol / 2) { + outputBase.set(JAVA_BYTE, output, (byte) size); + return size + 1; // header + size + } + else { + int entryCount = maxSymbol; + + size = (entryCount + 1) / 2; + checkArgument(size + 1 <= outputSize, "Output size too small"); + + outputBase.set(JAVA_BYTE, output, (byte) (127 + entryCount)); + output++; + + weights[maxSymbol] = 0; + for (int i = 0; i < entryCount; i += 2) { + outputBase.set(JAVA_BYTE, output, (byte) ((weights[i] << 4) + weights[i + 1])); + output++; + } + + return (int) (output - outputAddress); + } + } + + /** + * Can this table encode all symbols with non-zero count? + */ + public boolean isValid(int[] counts, int maxSymbol) + { + if (maxSymbol > this.maxSymbol) { + return false; + } + + for (int symbol = 0; symbol <= maxSymbol; ++symbol) { + if (counts[symbol] != 0 && numberOfBits[symbol] == 0) { + return false; + } + } + return true; + } + + public int estimateCompressedSize(int[] counts, int maxSymbol) + { + int numberOfBits = 0; + for (int symbol = 0; symbol <= Math.min(maxSymbol, this.maxSymbol); symbol++) { + numberOfBits += this.numberOfBits[symbol] * counts[symbol]; + } + + return numberOfBits >>> 3; + } + + private static int setMaxHeight(NodeTable nodeTable, int lastNonZero, int maxNumberOfBits, HuffmanCompressionTableWorkspace workspace) + { + int largestBits = nodeTable.numberOfBits[lastNonZero]; + + if (largestBits <= maxNumberOfBits) { + return largestBits; + } + + int totalCost = 0; + int baseCost = 1 << (largestBits - maxNumberOfBits); + int n = lastNonZero; + + while (nodeTable.numberOfBits[n] > maxNumberOfBits) { + totalCost += baseCost - (1 << (largestBits - nodeTable.numberOfBits[n])); + nodeTable.numberOfBits[n ] = (byte) maxNumberOfBits; + n--; + } + + while (nodeTable.numberOfBits[n] == maxNumberOfBits) { + n--; + } + + totalCost >>>= (largestBits - maxNumberOfBits); + + int noSymbol = 0xF0F0F0F0; + int[] rankLast = workspace.rankLast; + Arrays.fill(rankLast, noSymbol); + + int currentNbBits = maxNumberOfBits; + for (int pos = n; pos >= 0; pos--) { + if (nodeTable.numberOfBits[pos] >= currentNbBits) { + continue; + } + currentNbBits = nodeTable.numberOfBits[pos]; + rankLast[maxNumberOfBits - currentNbBits] = pos; + } + + while (totalCost > 0) { + int numberOfBitsToDecrease = Util.highestBit(totalCost) + 1; + for (; numberOfBitsToDecrease > 1; numberOfBitsToDecrease--) { + int highPosition = rankLast[numberOfBitsToDecrease]; + int lowPosition = rankLast[numberOfBitsToDecrease - 1]; + if (highPosition == noSymbol) { + continue; + } + if (lowPosition == noSymbol) { + break; + } + int highTotal = nodeTable.count[highPosition]; + int lowTotal = 2 * nodeTable.count[lowPosition]; + if (highTotal <= lowTotal) { + break; + } + } + + while ((numberOfBitsToDecrease <= MAX_TABLE_LOG) && (rankLast[numberOfBitsToDecrease] == noSymbol)) { + numberOfBitsToDecrease++; + } + totalCost -= 1 << (numberOfBitsToDecrease - 1); + if (rankLast[numberOfBitsToDecrease - 1] == noSymbol) { + rankLast[numberOfBitsToDecrease - 1] = rankLast[numberOfBitsToDecrease]; + } + nodeTable.numberOfBits[rankLast[numberOfBitsToDecrease]]++; + if (rankLast[numberOfBitsToDecrease] == 0) { + rankLast[numberOfBitsToDecrease] = noSymbol; + } + else { + rankLast[numberOfBitsToDecrease]--; + if (nodeTable.numberOfBits[rankLast[numberOfBitsToDecrease]] != maxNumberOfBits - numberOfBitsToDecrease) { + rankLast[numberOfBitsToDecrease] = noSymbol; + } + } + } + + while (totalCost < 0) { + if (rankLast[1] == noSymbol) { + while (nodeTable.numberOfBits[n] == maxNumberOfBits) { + n--; + } + nodeTable.numberOfBits[n + 1]--; + rankLast[1] = n + 1; + totalCost++; + continue; + } + nodeTable.numberOfBits[rankLast[1] + 1]--; + rankLast[1]++; + totalCost++; + } + + return maxNumberOfBits; + } + + private static int compressWeights(MemorySegment outputBase, long outputAddress, int outputSize, byte[] weights, int weightsLength, HuffmanTableWriterWorkspace workspace) + { + if (weightsLength <= 1) { + return 0; + } + + int[] counts = workspace.counts; + Histogram.count(weights, weightsLength, counts); + int maxSymbol = Histogram.findMaxSymbol(counts, MAX_TABLE_LOG); + int maxCount = Histogram.findLargestCount(counts, maxSymbol); + + if (maxCount == weightsLength) { + return 1; + } + if (maxCount == 1) { + return 0; + } + + short[] normalizedCounts = workspace.normalizedCounts; + + int tableLog = FiniteStateEntropy.optimalTableLog(MAX_FSE_TABLE_LOG, weightsLength, maxSymbol); + FiniteStateEntropy.normalizeCounts(normalizedCounts, tableLog, counts, weightsLength, maxSymbol); + + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int headerSize = FiniteStateEntropy.writeNormalizedCounts(outputBase, output, outputSize, normalizedCounts, maxSymbol, tableLog); + output += headerSize; + + FseCompressionTable compressionTable = workspace.fseTable; + compressionTable.initialize(normalizedCounts, maxSymbol, tableLog); + int compressedSize = FiniteStateEntropy.compress(outputBase, output, (int) (outputLimit - output), weights, weightsLength, compressionTable); + if (compressedSize == 0) { + return 0; + } + output += compressedSize; + + return (int) (output - outputAddress); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java new file mode 100644 index 00000000..ca20b10a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressionTableWorkspace.java @@ -0,0 +1,33 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class HuffmanCompressionTableWorkspace +{ + public final NodeTable nodeTable = new NodeTable((2 * Huffman.MAX_SYMBOL_COUNT - 1)); // number of nodes in binary tree with MAX_SYMBOL_COUNT leaves + + public final short[] entriesPerRank = new short[Huffman.MAX_TABLE_LOG + 1]; + public final short[] valuesPerRank = new short[Huffman.MAX_TABLE_LOG + 1]; + + // for setMaxHeight + public final int[] rankLast = new int[Huffman.MAX_TABLE_LOG + 2]; + + public void reset() + { + Arrays.fill(entriesPerRank, (short) 0); + Arrays.fill(valuesPerRank, (short) 0); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java new file mode 100644 index 00000000..a4985456 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanCompressor.java @@ -0,0 +1,139 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; + +final class HuffmanCompressor +{ + private HuffmanCompressor() + { + } + + public static int compress4streams(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, HuffmanCompressionTable table) + { + long input = inputAddress; + long inputLimit = inputAddress + inputSize; + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + int segmentSize = (inputSize + 3) / 4; + + if (outputSize < 6 + 1 + 1 + 1 + 8) { + return 0; + } + + if (inputSize <= 6 + 1 + 1 + 1) { + return 0; + } + + output += SIZE_OF_SHORT + SIZE_OF_SHORT + SIZE_OF_SHORT; + + int compressedSize; + + // first segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // second segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress + SIZE_OF_SHORT, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // third segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, segmentSize, table); + if (compressedSize == 0) { + return 0; + } + outputBase.set(JAVA_SHORT, outputAddress + SIZE_OF_SHORT + SIZE_OF_SHORT, (short) compressedSize); + output += compressedSize; + input += segmentSize; + + // fourth segment + compressedSize = compressSingleStream(outputBase, output, (int) (outputLimit - output), inputBase, input, (int) (inputLimit - input), table); + if (compressedSize == 0) { + return 0; + } + output += compressedSize; + + return (int) (output - outputAddress); + } + + public static int compressSingleStream(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize, HuffmanCompressionTable table) + { + if (outputSize < SIZE_OF_LONG) { + return 0; + } + + BitOutputStream bitstream = new BitOutputStream(outputBase, outputAddress, outputSize); + long input = inputAddress; + + int n = inputSize & ~3; + + switch (inputSize & 3) { + case 3: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 2) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 4 + 7) { + bitstream.flush(); + } + // fall-through + case 2: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 1) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + // fall-through + case 1: + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n + 0) & 0xFF); + bitstream.flush(); + // fall-through + case 0: /* fall-through */ + default: + break; + } + + for (; n > 0; n -= 4) { + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 1) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 2) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 4 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 3) & 0xFF); + if (SIZE_OF_LONG * 8 < Huffman.MAX_TABLE_LOG * 2 + 7) { + bitstream.flush(); + } + table.encodeSymbol(bitstream, inputBase.get(JAVA_BYTE, input + n - 4) & 0xFF); + bitstream.flush(); + } + + return bitstream.close(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java new file mode 100644 index 00000000..8f8227ca --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/HuffmanTableWriterWorkspace.java @@ -0,0 +1,29 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_FSE_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_TABLE_LOG; + +class HuffmanTableWriterWorkspace +{ + // for encoding weights + public final byte[] weights = new byte[MAX_SYMBOL]; // the weight for the last symbol is implicit + + // for compressing weights + public final int[] counts = new int[MAX_TABLE_LOG + 1]; + public final short[] normalizedCounts = new short[MAX_TABLE_LOG + 1]; + public final FseCompressionTable fseTable = new FseCompressionTable(MAX_FSE_TABLE_LOG, MAX_TABLE_LOG); +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java b/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java new file mode 100644 index 00000000..67744690 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/NodeTable.java @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.util.Arrays; + +class NodeTable +{ + int[] count; + short[] parents; + int[] symbols; + byte[] numberOfBits; + + public NodeTable(int size) + { + count = new int[size]; + parents = new short[size]; + symbols = new int[size]; + numberOfBits = new byte[size]; + } + + public void reset() + { + Arrays.fill(count, 0); + Arrays.fill(parents, (short) 0); + Arrays.fill(symbols, 0); + Arrays.fill(numberOfBits, (byte) 0); + } + + public void copyNode(int from, int to) + { + count[to] = count[from]; + parents[to] = parents[from]; + symbols[to] = symbols[from]; + numberOfBits[to] = numberOfBits[from]; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/README.md b/src/main/java/io/airlift/compress/v3/zstdFFM/README.md new file mode 100644 index 00000000..53537505 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/README.md @@ -0,0 +1,25 @@ +# Vendored: io.airlift.compress.v3.zstdFFM + +Copied verbatim (package name preserved) from +https://github.com/ebremer/aircompressor/tree/zstdFFM/src/main/java/io/airlift/compress/v3/zstdFFM +on 2026-07-02. + +This is the FFM (Foreign Function & Memory API) port of aircompressor's pure-Java +Zstd codec — it replaces the sun.misc.Unsafe-based implementation in +`io.airlift.compress.v3.zstd`. It is vendored here TEMPORARILY until the upstream +aircompressor project publishes its own release containing this package, at which +point this directory should be deleted and imports switched to the released +artifact. + +One file is intentionally NOT vendored: `ZstdCodec.java`. It extends +`io.airlift.compress.v3.hadoop.CodecAdapter`, which implements +`org.apache.hadoop.io.compress.CompressionCodec` — upstream compiles it against +a `provided` Hadoop dependency that BeakGraph neither has nor wants. BeakGraph +does not use Hadoop compression codecs. + +Otherwise, do not modify these files locally; make changes in the aircompressor fork and +re-copy. The classes still depend on the `aircompressor-v3` jar for the shared +`Compressor`/`Decompressor` interfaces, `MalformedInputException`, and +`internal.NativeLoader`. + +License: Apache License 2.0 (headers retained in each file). diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java b/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java new file mode 100644 index 00000000..12b26207 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/RepeatedOffsets.java @@ -0,0 +1,49 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +class RepeatedOffsets +{ + private int offset0 = 1; + private int offset1 = 4; + + private int tempOffset0; + private int tempOffset1; + + public int getOffset0() + { + return offset0; + } + + public int getOffset1() + { + return offset1; + } + + public void saveOffset0(int offset) + { + tempOffset0 = offset; + } + + public void saveOffset1(int offset) + { + tempOffset1 = offset; + } + + public void commit() + { + offset0 = tempOffset0; + offset1 = tempOffset1; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java new file mode 100644 index 00000000..c022245f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncoder.java @@ -0,0 +1,339 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.DEFAULT_MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERALS_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERAL_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.LONG_NUMBER_OF_SEQUENCES; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.OFFSET_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_BASIC; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_COMPRESSED; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_RLE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.FiniteStateEntropy.optimalTableLog; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; + +final class SequenceEncoder +{ + private static final int DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG = 6; + private static final short[] DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS = {4, 3, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 1, 1, 1, + 2, 2, 2, 2, 2, 2, 2, 2, + 2, 3, 2, 1, 1, 1, 1, 1, + -1, -1, -1, -1}; + + private static final int DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS_LOG = 6; + private static final short[] DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS = {1, 4, 3, 2, 2, 2, 2, 2, + 2, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, -1, -1, + -1, -1, -1, -1, -1}; + + private static final int DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG = 5; + private static final short[] DEFAULT_OFFSET_NORMALIZED_COUNTS = {1, 1, 1, 1, 1, 1, 2, 2, + 2, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + -1, -1, -1, -1, -1}; + + private static final FseCompressionTable DEFAULT_LITERAL_LENGTHS_TABLE = FseCompressionTable.newInstance(DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS, MAX_LITERALS_LENGTH_SYMBOL, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG); + private static final FseCompressionTable DEFAULT_MATCH_LENGTHS_TABLE = FseCompressionTable.newInstance(DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS, MAX_MATCH_LENGTH_SYMBOL, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG); + private static final FseCompressionTable DEFAULT_OFFSETS_TABLE = FseCompressionTable.newInstance(DEFAULT_OFFSET_NORMALIZED_COUNTS, DEFAULT_MAX_OFFSET_CODE_SYMBOL, DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG); + + private SequenceEncoder() + { + } + + public static int compressSequences(MemorySegment outputBase, final long outputAddress, int outputSize, SequenceStore sequences, CompressionParameters.Strategy strategy, SequenceEncodingContext workspace) + { + long output = outputAddress; + long outputLimit = outputAddress + outputSize; + + checkArgument(outputLimit - output > 3 + 1, "Output buffer too small"); + + int sequenceCount = sequences.sequenceCount; + if (sequenceCount < 0x7F) { + outputBase.set(JAVA_BYTE, output, (byte) sequenceCount); + output++; + } + else if (sequenceCount < LONG_NUMBER_OF_SEQUENCES) { + outputBase.set(JAVA_BYTE, output, (byte) (sequenceCount >>> 8 | 0x80)); + outputBase.set(JAVA_BYTE, output + 1, (byte) sequenceCount); + output += SIZE_OF_SHORT; + } + else { + outputBase.set(JAVA_BYTE, output, (byte) 0xFF); + output++; + outputBase.set(JAVA_SHORT, output, (short) (sequenceCount - LONG_NUMBER_OF_SEQUENCES)); + output += SIZE_OF_SHORT; + } + + if (sequenceCount == 0) { + return (int) (output - outputAddress); + } + + // flags for FSE encoding type + long headerAddress = output++; + + int maxSymbol; + int largestCount; + + // literal lengths + int[] counts = workspace.counts; + Histogram.count(sequences.literalLengthCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_LITERALS_LENGTH_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + int literalsLengthEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_LITERAL_LENGTH_NORMALIZED_COUNTS_LOG, true, strategy); + + FseCompressionTable literalLengthTable; + switch (literalsLengthEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.literalLengthCodes[0]); + output++; + workspace.literalLengthTable.initializeRleTable(maxSymbol); + literalLengthTable = workspace.literalLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + literalLengthTable = DEFAULT_LITERAL_LENGTHS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.literalLengthTable, + outputBase, + output, + outputLimit, + sequenceCount, + LITERAL_LENGTH_TABLE_LOG, + sequences.literalLengthCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + literalLengthTable = workspace.literalLengthTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // offsets + Histogram.count(sequences.offsetCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_OFFSET_CODE_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + boolean defaultAllowed = maxSymbol < DEFAULT_MAX_OFFSET_CODE_SYMBOL; + + int offsetEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_OFFSET_NORMALIZED_COUNTS_LOG, defaultAllowed, strategy); + + FseCompressionTable offsetCodeTable; + switch (offsetEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.offsetCodes[0]); + output++; + workspace.offsetCodeTable.initializeRleTable(maxSymbol); + offsetCodeTable = workspace.offsetCodeTable; + break; + case SEQUENCE_ENCODING_BASIC: + offsetCodeTable = DEFAULT_OFFSETS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.offsetCodeTable, + outputBase, + output, + output + outputSize, + sequenceCount, + OFFSET_TABLE_LOG, + sequences.offsetCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + offsetCodeTable = workspace.offsetCodeTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // match lengths + Histogram.count(sequences.matchLengthCodes, sequenceCount, workspace.counts); + maxSymbol = Histogram.findMaxSymbol(counts, MAX_MATCH_LENGTH_SYMBOL); + largestCount = Histogram.findLargestCount(counts, maxSymbol); + + int matchLengthEncodingType = selectEncodingType(largestCount, sequenceCount, DEFAULT_MATCH_LENGTH_NORMALIZED_COUNTS_LOG, true, strategy); + + FseCompressionTable matchLengthTable; + switch (matchLengthEncodingType) { + case SEQUENCE_ENCODING_RLE: + outputBase.set(JAVA_BYTE, output, sequences.matchLengthCodes[0]); + output++; + workspace.matchLengthTable.initializeRleTable(maxSymbol); + matchLengthTable = workspace.matchLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + matchLengthTable = DEFAULT_MATCH_LENGTHS_TABLE; + break; + case SEQUENCE_ENCODING_COMPRESSED: + output += buildCompressionTable( + workspace.matchLengthTable, + outputBase, + output, + outputLimit, + sequenceCount, + MATCH_LENGTH_TABLE_LOG, + sequences.matchLengthCodes, + workspace.counts, + maxSymbol, + workspace.normalizedCounts); + matchLengthTable = workspace.matchLengthTable; + break; + default: + throw new UnsupportedOperationException("not yet implemented"); + } + + // flags + outputBase.set(JAVA_BYTE, headerAddress, (byte) ((literalsLengthEncodingType << 6) | (offsetEncodingType << 4) | (matchLengthEncodingType << 2))); + + output += encodeSequences(outputBase, output, outputLimit, matchLengthTable, offsetCodeTable, literalLengthTable, sequences); + + return (int) (output - outputAddress); + } + + private static int buildCompressionTable(FseCompressionTable table, MemorySegment outputBase, long output, long outputLimit, int sequenceCount, int maxTableLog, byte[] codes, int[] counts, int maxSymbol, short[] normalizedCounts) + { + int tableLog = optimalTableLog(maxTableLog, sequenceCount, maxSymbol); + + if (counts[codes[sequenceCount - 1]] > 1) { + counts[codes[sequenceCount - 1]]--; + sequenceCount--; + } + + FiniteStateEntropy.normalizeCounts(normalizedCounts, tableLog, counts, sequenceCount, maxSymbol); + table.initialize(normalizedCounts, maxSymbol, tableLog); + + return FiniteStateEntropy.writeNormalizedCounts(outputBase, output, (int) (outputLimit - output), normalizedCounts, maxSymbol, tableLog); + } + + private static int encodeSequences( + MemorySegment outputBase, + long output, + long outputLimit, + FseCompressionTable matchLengthTable, + FseCompressionTable offsetsTable, + FseCompressionTable literalLengthTable, + SequenceStore sequences) + { + byte[] matchLengthCodes = sequences.matchLengthCodes; + byte[] offsetCodes = sequences.offsetCodes; + byte[] literalLengthCodes = sequences.literalLengthCodes; + + BitOutputStream blockStream = new BitOutputStream(outputBase, output, (int) (outputLimit - output)); + + int sequenceCount = sequences.sequenceCount; + + // first symbols + int matchLengthState = matchLengthTable.begin(matchLengthCodes[sequenceCount - 1]); + int offsetState = offsetsTable.begin(offsetCodes[sequenceCount - 1]); + int literalLengthState = literalLengthTable.begin(literalLengthCodes[sequenceCount - 1]); + + blockStream.addBits(sequences.literalLengths[sequenceCount - 1], LITERALS_LENGTH_BITS[literalLengthCodes[sequenceCount - 1]]); + blockStream.addBits(sequences.matchLengths[sequenceCount - 1], MATCH_LENGTH_BITS[matchLengthCodes[sequenceCount - 1]]); + blockStream.addBits(sequences.offsets[sequenceCount - 1], offsetCodes[sequenceCount - 1]); + blockStream.flush(); + + if (sequenceCount >= 2) { + for (int n = sequenceCount - 2; n >= 0; n--) { + byte literalLengthCode = literalLengthCodes[n]; + byte offsetCode = offsetCodes[n]; + byte matchLengthCode = matchLengthCodes[n]; + + int literalLengthBits = LITERALS_LENGTH_BITS[literalLengthCode]; + int offsetBits = offsetCode; + int matchLengthBits = MATCH_LENGTH_BITS[matchLengthCode]; + + offsetState = offsetsTable.encode(blockStream, offsetState, offsetCode); + matchLengthState = matchLengthTable.encode(blockStream, matchLengthState, matchLengthCode); + literalLengthState = literalLengthTable.encode(blockStream, literalLengthState, literalLengthCode); + + if ((offsetBits + matchLengthBits + literalLengthBits >= 64 - 7 - (LITERAL_LENGTH_TABLE_LOG + MATCH_LENGTH_TABLE_LOG + OFFSET_TABLE_LOG))) { + blockStream.flush(); + } + + blockStream.addBits(sequences.literalLengths[n], literalLengthBits); + if (((literalLengthBits + matchLengthBits) > 24)) { + blockStream.flush(); + } + + blockStream.addBits(sequences.matchLengths[n], matchLengthBits); + if ((offsetBits + matchLengthBits + literalLengthBits > 56)) { + blockStream.flush(); + } + + blockStream.addBits(sequences.offsets[n], offsetBits); + blockStream.flush(); + } + } + + matchLengthTable.finish(blockStream, matchLengthState); + offsetsTable.finish(blockStream, offsetState); + literalLengthTable.finish(blockStream, literalLengthState); + + int streamSize = blockStream.close(); + checkArgument(streamSize > 0, "Output buffer too small"); + + return streamSize; + } + + private static int selectEncodingType( + int largestCount, + int sequenceCount, + int defaultNormalizedCountsLog, + boolean isDefaultTableAllowed, + CompressionParameters.Strategy strategy) + { + if (largestCount == sequenceCount) { + if (isDefaultTableAllowed && sequenceCount <= 2) { + return SEQUENCE_ENCODING_BASIC; + } + + return SEQUENCE_ENCODING_RLE; + } + + if (strategy.ordinal() < CompressionParameters.Strategy.LAZY.ordinal()) { + if (isDefaultTableAllowed) { + int factor = 10 - strategy.ordinal(); + int baseLog = 3; + long minNumberOfSequences = ((1L << defaultNormalizedCountsLog) * factor) >> baseLog; + + if ((sequenceCount < minNumberOfSequences) || (largestCount < (sequenceCount >> (defaultNormalizedCountsLog - 1)))) { + return SEQUENCE_ENCODING_BASIC; + } + } + } + else { + throw new UnsupportedOperationException("not yet implemented"); + } + + return SEQUENCE_ENCODING_COMPRESSED; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java new file mode 100644 index 00000000..30f5f11a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceEncodingContext.java @@ -0,0 +1,30 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_OFFSET_CODE_SYMBOL; + +class SequenceEncodingContext +{ + private static final int MAX_SEQUENCES = Math.max(MAX_LITERALS_LENGTH_SYMBOL, MAX_MATCH_LENGTH_SYMBOL); + + public final FseCompressionTable literalLengthTable = new FseCompressionTable(Constants.LITERAL_LENGTH_TABLE_LOG, MAX_LITERALS_LENGTH_SYMBOL); + public final FseCompressionTable offsetCodeTable = new FseCompressionTable(Constants.OFFSET_TABLE_LOG, MAX_OFFSET_CODE_SYMBOL); + public final FseCompressionTable matchLengthTable = new FseCompressionTable(Constants.MATCH_LENGTH_TABLE_LOG, MAX_MATCH_LENGTH_SYMBOL); + + public final int[] counts = new int[MAX_SEQUENCES + 1]; + public final short[] normalizedCounts = new short[MAX_SEQUENCES + 1]; +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java new file mode 100644 index 00000000..8fc141cb --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/SequenceStore.java @@ -0,0 +1,159 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; + +final class SequenceStore +{ + public final byte[] literalsBuffer; + public final MemorySegment literalsBufferSegment; + public int literalsLength; + + public final int[] offsets; + public final int[] literalLengths; + public final int[] matchLengths; + public int sequenceCount; + + public final byte[] literalLengthCodes; + public final byte[] matchLengthCodes; + public final byte[] offsetCodes; + + public LongField longLengthField; + public int longLengthPosition; + + public enum LongField + { + LITERAL, MATCH + } + + private static final byte[] LITERAL_LENGTH_CODE = {0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + 16, 16, 17, 17, 18, 18, 19, 19, + 20, 20, 20, 20, 21, 21, 21, 21, + 22, 22, 22, 22, 22, 22, 22, 22, + 23, 23, 23, 23, 23, 23, 23, 23, + 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24}; + + private static final byte[] MATCH_LENGTH_CODE = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 36, 36, 37, 37, 37, 37, + 38, 38, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 39, 39, 39, 39, + 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, 40, + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, + 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, + 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42, 42}; + + public SequenceStore(int blockSize, int maxSequences) + { + offsets = new int[maxSequences]; + literalLengths = new int[maxSequences]; + matchLengths = new int[maxSequences]; + + literalLengthCodes = new byte[maxSequences]; + matchLengthCodes = new byte[maxSequences]; + offsetCodes = new byte[maxSequences]; + + literalsBuffer = new byte[blockSize]; + literalsBufferSegment = MemorySegment.ofArray(literalsBuffer); + + reset(); + } + + public void appendLiterals(MemorySegment inputBase, long inputAddress, int inputSize) + { + MemorySegment.copy(inputBase, inputAddress, literalsBufferSegment, literalsLength, inputSize); + literalsLength += inputSize; + } + + public void storeSequence(MemorySegment literalBase, long literalAddress, int literalLength, int offsetCode, int matchLengthBase) + { + long input = literalAddress; + long output = literalsLength; + int copied = 0; + do { + literalsBufferSegment.set(JAVA_LONG, output, literalBase.get(JAVA_LONG, input)); + input += SIZE_OF_LONG; + output += SIZE_OF_LONG; + copied += SIZE_OF_LONG; + } + while (copied < literalLength); + + literalsLength += literalLength; + + if (literalLength > 65535) { + longLengthField = LongField.LITERAL; + longLengthPosition = sequenceCount; + } + literalLengths[sequenceCount] = literalLength; + + offsets[sequenceCount] = offsetCode + 1; + + if (matchLengthBase > 65535) { + longLengthField = LongField.MATCH; + longLengthPosition = sequenceCount; + } + + matchLengths[sequenceCount] = matchLengthBase; + + sequenceCount++; + } + + public void reset() + { + literalsLength = 0; + sequenceCount = 0; + longLengthField = null; + } + + public void generateCodes() + { + for (int i = 0; i < sequenceCount; ++i) { + literalLengthCodes[i] = (byte) literalLengthToCode(literalLengths[i]); + offsetCodes[i] = (byte) Util.highestBit(offsets[i]); + matchLengthCodes[i] = (byte) matchLengthToCode(matchLengths[i]); + } + + if (longLengthField == LongField.LITERAL) { + literalLengthCodes[longLengthPosition] = Constants.MAX_LITERALS_LENGTH_SYMBOL; + } + if (longLengthField == LongField.MATCH) { + matchLengthCodes[longLengthPosition] = Constants.MAX_MATCH_LENGTH_SYMBOL; + } + } + + private static int literalLengthToCode(int literalLength) + { + if (literalLength >= 64) { + return Util.highestBit(literalLength) + 19; + } + else { + return LITERAL_LENGTH_CODE[literalLength]; + } + } + + private static int matchLengthToCode(int matchLengthBase) + { + if (matchLengthBase >= 128) { + return Util.highestBit(matchLengthBase) + 36; + } + else { + return MATCH_LENGTH_CODE[matchLengthBase]; + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java b/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java new file mode 100644 index 00000000..7a1dc52f --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/Util.java @@ -0,0 +1,136 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; + +final class Util +{ + private Util() + { + } + + public static int highestBit(int value) + { + return 31 - Integer.numberOfLeadingZeros(value); + } + + public static boolean isPowerOf2(int value) + { + return (value & (value - 1)) == 0; + } + + public static int mask(int bits) + { + return (1 << bits) - 1; + } + + public static void verify(boolean condition, long offset, String reason) + { + if (!condition) { + throw new MalformedInputException(offset, reason); + } + } + + public static void checkArgument(boolean condition, String reason) + { + if (!condition) { + throw new IllegalArgumentException(reason); + } + } + + static void checkPositionIndexes(int start, int end, int size) + { + // Carefully optimized for execution by hotspot (explanatory comment above) + if (start < 0 || end < start || end > size) { + throw new IndexOutOfBoundsException(badPositionIndexes(start, end, size)); + } + } + + private static String badPositionIndexes(int start, int end, int size) + { + if (start < 0 || start > size) { + return badPositionIndex(start, size, "start index"); + } + if (end < 0 || end > size) { + return badPositionIndex(end, size, "end index"); + } + // end < start + return String.format("end index (%s) must not be less than start index (%s)", end, start); + } + + private static String badPositionIndex(int index, int size, String desc) + { + if (index < 0) { + return String.format("%s (%s) must not be negative", desc, index); + } + else if (size < 0) { + throw new IllegalArgumentException("negative size: " + size); + } + else { // index > size + return String.format("%s (%s) must not be greater than size (%s)", desc, index, size); + } + } + + public static void checkState(boolean condition, String reason) + { + if (!condition) { + throw new IllegalStateException(reason); + } + } + + public static MalformedInputException fail(long offset, String reason) + { + throw new MalformedInputException(offset, reason); + } + + public static int cycleLog(int hashLog, CompressionParameters.Strategy strategy) + { + int cycleLog = hashLog; + if (strategy == CompressionParameters.Strategy.BTLAZY2 || strategy == CompressionParameters.Strategy.BTOPT || strategy == CompressionParameters.Strategy.BTULTRA) { + cycleLog = hashLog - 1; + } + return cycleLog; + } + + public static int get24BitLittleEndian(MemorySegment inputBase, long inputAddress) + { + return (inputBase.get(JAVA_SHORT, inputAddress) & 0xFFFF) + | ((inputBase.get(JAVA_BYTE, inputAddress + SIZE_OF_SHORT) & 0xFF) << Short.SIZE); + } + + public static void put24BitLittleEndian(MemorySegment outputBase, long outputAddress, int value) + { + outputBase.set(JAVA_SHORT, outputAddress, (short) value); + outputBase.set(JAVA_BYTE, outputAddress + SIZE_OF_SHORT, (byte) (value >>> Short.SIZE)); + } + + // provides the minimum logSize to safely represent a distribution + public static int minTableLog(int inputSize, int maxSymbolValue) + { + if (inputSize <= 1) { + throw new IllegalArgumentException("Not supported. RLE should be used instead"); // TODO + } + + int minBitsSrc = highestBit((inputSize - 1)) + 1; + int minBitsSymbols = highestBit(maxSymbolValue) + 2; + return Math.min(minBitsSrc, minBitsSymbols); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java b/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java new file mode 100644 index 00000000..c216e713 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/XxHash64.java @@ -0,0 +1,291 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkPositionIndexes; +import static java.lang.Long.rotateLeft; +import static java.lang.Math.min; + +// FFM-based implementation forked from the Unsafe-based XxHash64 in the zstd package. +final class XxHash64 +{ + private static final long PRIME64_1 = 0x9E3779B185EBCA87L; + private static final long PRIME64_2 = 0xC2B2AE3D27D4EB4FL; + private static final long PRIME64_3 = 0x165667B19E3779F9L; + private static final long PRIME64_4 = 0x85EBCA77C2b2AE63L; + private static final long PRIME64_5 = 0x27D4EB2F165667C5L; + + private static final long DEFAULT_SEED = 0; + + private final long seed; + + private final byte[] buffer = new byte[32]; + private final MemorySegment bufferSegment = MemorySegment.ofArray(buffer); + private int bufferSize; + + private long bodyLength; + + private long v1; + private long v2; + private long v3; + private long v4; + + public XxHash64() + { + this(DEFAULT_SEED); + } + + private XxHash64(long seed) + { + this.seed = seed; + this.v1 = seed + PRIME64_1 + PRIME64_2; + this.v2 = seed + PRIME64_2; + this.v3 = seed; + this.v4 = seed - PRIME64_1; + } + + public XxHash64 update(byte[] data) + { + return update(data, 0, data.length); + } + + public XxHash64 update(byte[] data, int offset, int length) + { + checkPositionIndexes(offset, offset + length, data.length); + updateHash(MemorySegment.ofArray(data), offset, length); + return this; + } + + public long hash() + { + long hash; + if (bodyLength > 0) { + hash = computeBody(); + } + else { + hash = seed + PRIME64_5; + } + + hash += bodyLength + bufferSize; + + return updateTail(hash, bufferSegment, 0, 0, bufferSize); + } + + private long computeBody() + { + long hash = rotateLeft(v1, 1) + rotateLeft(v2, 7) + rotateLeft(v3, 12) + rotateLeft(v4, 18); + + hash = update(hash, v1); + hash = update(hash, v2); + hash = update(hash, v3); + hash = update(hash, v4); + + return hash; + } + + private void updateHash(MemorySegment base, long address, int length) + { + if (bufferSize > 0) { + int available = min(32 - bufferSize, length); + + MemorySegment.copy(base, address, bufferSegment, bufferSize, available); + + bufferSize += available; + address += available; + length -= available; + + if (bufferSize == 32) { + updateBody(bufferSegment, 0, bufferSize); + bufferSize = 0; + } + } + + if (length >= 32) { + int index = updateBody(base, address, length); + address += index; + length -= index; + } + + if (length > 0) { + MemorySegment.copy(base, address, bufferSegment, 0, length); + bufferSize = length; + } + } + + private int updateBody(MemorySegment base, long address, int length) + { + int remaining = length; + while (remaining >= 32) { + v1 = mix(v1, base.get(JAVA_LONG, address)); + v2 = mix(v2, base.get(JAVA_LONG, address + 8)); + v3 = mix(v3, base.get(JAVA_LONG, address + 16)); + v4 = mix(v4, base.get(JAVA_LONG, address + 24)); + + address += 32; + remaining -= 32; + } + + int index = length - remaining; + bodyLength += index; + return index; + } + + public static long hash(long value) + { + long hash = DEFAULT_SEED + PRIME64_5 + SIZE_OF_LONG; + hash = updateTail(hash, value); + hash = finalShuffle(hash); + + return hash; + } + + public static long hash(InputStream in) + throws IOException + { + return hash(DEFAULT_SEED, in); + } + + public static long hash(long seed, InputStream in) + throws IOException + { + XxHash64 hash = new XxHash64(seed); + byte[] buffer = new byte[8192]; + while (true) { + int length = in.read(buffer); + if (length == -1) { + break; + } + hash.update(buffer, 0, length); + } + return hash.hash(); + } + + public static long hash(long seed, MemorySegment base, long address, int length) + { + long hash; + if (length >= 32) { + hash = updateBody(seed, base, address, length); + } + else { + hash = seed + PRIME64_5; + } + + hash += length; + + // round to the closest 32 byte boundary + // this is the point up to which updateBody() processed + int index = length & 0xFFFFFFE0; + + return updateTail(hash, base, address, index, length); + } + + private static long updateTail(long hash, MemorySegment base, long address, int index, int length) + { + while (index <= length - 8) { + hash = updateTail(hash, base.get(JAVA_LONG, address + index)); + index += 8; + } + + if (index <= length - 4) { + hash = updateTail(hash, base.get(JAVA_INT, address + index)); + index += 4; + } + + while (index < length) { + hash = updateTail(hash, base.get(JAVA_BYTE, address + index)); + index++; + } + + hash = finalShuffle(hash); + + return hash; + } + + private static long updateBody(long seed, MemorySegment base, long address, int length) + { + long v1 = seed + PRIME64_1 + PRIME64_2; + long v2 = seed + PRIME64_2; + long v3 = seed; + long v4 = seed - PRIME64_1; + + int remaining = length; + while (remaining >= 32) { + v1 = mix(v1, base.get(JAVA_LONG, address)); + v2 = mix(v2, base.get(JAVA_LONG, address + 8)); + v3 = mix(v3, base.get(JAVA_LONG, address + 16)); + v4 = mix(v4, base.get(JAVA_LONG, address + 24)); + + address += 32; + remaining -= 32; + } + + long hash = rotateLeft(v1, 1) + rotateLeft(v2, 7) + rotateLeft(v3, 12) + rotateLeft(v4, 18); + + hash = update(hash, v1); + hash = update(hash, v2); + hash = update(hash, v3); + hash = update(hash, v4); + + return hash; + } + + private static long mix(long current, long value) + { + return rotateLeft(current + value * PRIME64_2, 31) * PRIME64_1; + } + + private static long update(long hash, long value) + { + long temp = hash ^ mix(0, value); + return temp * PRIME64_1 + PRIME64_4; + } + + private static long updateTail(long hash, long value) + { + long temp = hash ^ mix(0, value); + return rotateLeft(temp, 27) * PRIME64_1 + PRIME64_4; + } + + private static long updateTail(long hash, int value) + { + long unsigned = value & 0xFFFF_FFFFL; + long temp = hash ^ (unsigned * PRIME64_1); + return rotateLeft(temp, 23) * PRIME64_2 + PRIME64_3; + } + + private static long updateTail(long hash, byte value) + { + int unsigned = value & 0xFF; + long temp = hash ^ (unsigned * PRIME64_5); + return rotateLeft(temp, 11) * PRIME64_1; + } + + private static long finalShuffle(long hash) + { + hash ^= hash >>> 33; + hash *= PRIME64_2; + hash ^= hash >>> 29; + hash *= PRIME64_3; + hash ^= hash >>> 32; + return hash; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java new file mode 100644 index 00000000..f9784761 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdCompressor.java @@ -0,0 +1,43 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.Compressor; + +import java.lang.foreign.MemorySegment; + +public interface ZstdCompressor + extends Compressor +{ + int compress(MemorySegment input, MemorySegment output); + + static ZstdCompressor create() + { + if (ZstdNativeCompressor.isEnabled()) { + return new ZstdNativeCompressor(); + } + return new ZstdJavaCompressor(); + } + + static ZstdCompressor create(int compressionLevel) + { + if (ZstdNativeCompressor.isEnabled()) { + return new ZstdNativeCompressor(compressionLevel); + } + if (compressionLevel != CompressionParameters.DEFAULT_COMPRESSION_LEVEL) { + throw new IllegalArgumentException("Compression level different from default cannot be used for non-native Zstd compressor"); + } + return new ZstdJavaCompressor(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java new file mode 100644 index 00000000..0f3ca159 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdDecompressor.java @@ -0,0 +1,30 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.Decompressor; + +public interface ZstdDecompressor + extends Decompressor +{ + long getDecompressedSize(byte[] input, int offset, int length); + + static ZstdDecompressor create() + { + if (ZstdNativeDecompressor.isEnabled()) { + return new ZstdNativeDecompressor(); + } + return new ZstdJavaDecompressor(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java new file mode 100644 index 00000000..36bc8ab9 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameCompressor.java @@ -0,0 +1,432 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.MAGIC_NUMBER; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.Constants.TREELESS_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Huffman.MAX_SYMBOL_COUNT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.put24BitLittleEndian; + +final class ZstdFrameCompressor +{ + static final int MAX_FRAME_HEADER_SIZE = 14; + + private static final int CHECKSUM_FLAG = 0b100; + private static final int SINGLE_SEGMENT_FLAG = 0b100000; + + private static final int MINIMUM_LITERALS_SIZE = 63; + + // the maximum table log allowed for literal encoding per RFC 8478, section 4.2.1 + private static final int MAX_HUFFMAN_TABLE_LOG = 11; + + private ZstdFrameCompressor() + { + } + + // visible for testing + static int writeMagic(final MemorySegment outputBase, final long outputAddress, final long outputLimit) + { + checkArgument(outputLimit - outputAddress >= SIZE_OF_INT, "Output buffer too small"); + + outputBase.set(JAVA_INT, outputAddress, MAGIC_NUMBER); + return SIZE_OF_INT; + } + + // visible for testing + static int writeFrameHeader(final MemorySegment outputBase, final long outputAddress, final long outputLimit, int inputSize, int windowSize) + { + checkArgument(outputLimit - outputAddress >= MAX_FRAME_HEADER_SIZE, "Output buffer too small"); + + long output = outputAddress; + + int contentSizeDescriptor = 0; + if (inputSize != -1) { + contentSizeDescriptor = (inputSize >= 256 ? 1 : 0) + (inputSize >= 65536 + 256 ? 1 : 0); + } + int frameHeaderDescriptor = (contentSizeDescriptor << 6) | CHECKSUM_FLAG; + + boolean singleSegment = inputSize != -1 && windowSize >= inputSize; + if (singleSegment) { + frameHeaderDescriptor |= SINGLE_SEGMENT_FLAG; + } + + outputBase.set(JAVA_BYTE, output, (byte) frameHeaderDescriptor); + output++; + + if (!singleSegment) { + int base = Integer.highestOneBit(windowSize); + + int exponent = 32 - Integer.numberOfLeadingZeros(base) - 1; + if (exponent < MIN_WINDOW_LOG) { + throw new IllegalArgumentException("Minimum window size is " + (1 << MIN_WINDOW_LOG)); + } + + int remainder = windowSize - base; + if (remainder % (base / 8) != 0) { + throw new IllegalArgumentException("Window size of magnitude 2^" + exponent + " must be multiple of " + (base / 8)); + } + + int mantissa = remainder / (base / 8); + int encoded = ((exponent - MIN_WINDOW_LOG) << 3) | mantissa; + + outputBase.set(JAVA_BYTE, output, (byte) encoded); + output++; + } + + switch (contentSizeDescriptor) { + case 0: + if (singleSegment) { + outputBase.set(JAVA_BYTE, output++, (byte) inputSize); + } + break; + case 1: + outputBase.set(JAVA_SHORT, output, (short) (inputSize - 256)); + output += SIZE_OF_SHORT; + break; + case 2: + outputBase.set(JAVA_INT, output, inputSize); + output += SIZE_OF_INT; + break; + default: + throw new AssertionError(); + } + + return (int) (output - outputAddress); + } + + // visible for testing + static int writeChecksum(MemorySegment outputBase, long outputAddress, long outputLimit, MemorySegment inputBase, long inputAddress, long inputLimit) + { + checkArgument(outputLimit - outputAddress >= SIZE_OF_INT, "Output buffer too small"); + + int inputSize = (int) (inputLimit - inputAddress); + + long hash = XxHash64.hash(0, inputBase, inputAddress, inputSize); + + outputBase.set(JAVA_INT, outputAddress, (int) hash); + + return SIZE_OF_INT; + } + + public static int compress(MemorySegment inputBase, long inputAddress, long inputLimit, MemorySegment outputBase, long outputAddress, long outputLimit, int compressionLevel) + { + int inputSize = (int) (inputLimit - inputAddress); + + CompressionParameters parameters = CompressionParameters.compute(compressionLevel, inputSize); + + long output = outputAddress; + + output += writeMagic(outputBase, output, outputLimit); + output += writeFrameHeader(outputBase, output, outputLimit, inputSize, parameters.getWindowSize()); + output += compressFrame(inputBase, inputAddress, inputLimit, outputBase, output, outputLimit, parameters); + output += writeChecksum(outputBase, output, outputLimit, inputBase, inputAddress, inputLimit); + + return (int) (output - outputAddress); + } + + private static int compressFrame(MemorySegment inputBase, long inputAddress, long inputLimit, MemorySegment outputBase, long outputAddress, long outputLimit, CompressionParameters parameters) + { + int blockSize = parameters.getBlockSize(); + + int outputSize = (int) (outputLimit - outputAddress); + int remaining = (int) (inputLimit - inputAddress); + + long output = outputAddress; + long input = inputAddress; + + CompressionContext context = new CompressionContext(parameters, inputAddress, remaining); + do { + checkArgument(outputSize >= SIZE_OF_BLOCK_HEADER + MIN_BLOCK_SIZE, "Output buffer too small"); + + boolean lastBlock = blockSize >= remaining; + blockSize = Math.min(blockSize, remaining); + + int compressedSize = writeCompressedBlock(inputBase, input, blockSize, outputBase, output, outputSize, context, lastBlock); + + input += blockSize; + remaining -= blockSize; + output += compressedSize; + outputSize -= compressedSize; + } + while (remaining > 0); + + return (int) (output - outputAddress); + } + + static int writeCompressedBlock(MemorySegment inputBase, long input, int blockSize, MemorySegment outputBase, long output, int outputSize, CompressionContext context, boolean lastBlock) + { + checkArgument(lastBlock || blockSize == context.parameters.getBlockSize(), "Only last block can be smaller than block size"); + + int compressedSize = 0; + if (blockSize > 0) { + compressedSize = compressBlock(inputBase, input, blockSize, outputBase, output + SIZE_OF_BLOCK_HEADER, outputSize - SIZE_OF_BLOCK_HEADER, context); + } + + if (compressedSize == 0) { + checkArgument(blockSize + SIZE_OF_BLOCK_HEADER <= outputSize, "Output size too small"); + + int blockHeader = (lastBlock ? 1 : 0) | (RAW_BLOCK << 1) | (blockSize << 3); + put24BitLittleEndian(outputBase, output, blockHeader); + MemorySegment.copy(inputBase, input, outputBase, output + SIZE_OF_BLOCK_HEADER, blockSize); + compressedSize = SIZE_OF_BLOCK_HEADER + blockSize; + } + else { + int blockHeader = (lastBlock ? 1 : 0) | (COMPRESSED_BLOCK << 1) | (compressedSize << 3); + put24BitLittleEndian(outputBase, output, blockHeader); + compressedSize += SIZE_OF_BLOCK_HEADER; + } + return compressedSize; + } + + private static int compressBlock(MemorySegment inputBase, long inputAddress, int inputSize, MemorySegment outputBase, long outputAddress, int outputSize, CompressionContext context) + { + if (inputSize < MIN_BLOCK_SIZE + SIZE_OF_BLOCK_HEADER + 1) { + return 0; + } + + CompressionParameters parameters = context.parameters; + context.blockCompressionState.enforceMaxDistance(inputAddress + inputSize, parameters.getWindowSize()); + context.sequenceStore.reset(); + + int lastLiteralsSize = parameters.getStrategy() + .getCompressor() + .compressBlock(inputBase, inputAddress, inputSize, context.sequenceStore, context.blockCompressionState, context.offsets, parameters); + + long lastLiteralsAddress = inputAddress + inputSize - lastLiteralsSize; + + context.sequenceStore.appendLiterals(inputBase, lastLiteralsAddress, lastLiteralsSize); + + context.sequenceStore.generateCodes(); + + long outputLimit = outputAddress + outputSize; + long output = outputAddress; + + int compressedLiteralsSize = encodeLiterals( + context.huffmanContext, + parameters, + outputBase, + output, + (int) (outputLimit - output), + context.sequenceStore.literalsBuffer, + context.sequenceStore.literalsLength); + output += compressedLiteralsSize; + + int compressedSequencesSize = SequenceEncoder.compressSequences(outputBase, output, (int) (outputLimit - output), context.sequenceStore, parameters.getStrategy(), context.sequenceEncodingContext); + + int compressedSize = compressedLiteralsSize + compressedSequencesSize; + if (compressedSize == 0) { + return compressedSize; + } + + int maxCompressedSize = inputSize - calculateMinimumGain(inputSize, parameters.getStrategy()); + if (compressedSize > maxCompressedSize) { + return 0; + } + + context.commit(); + + return compressedSize; + } + + private static int encodeLiterals( + HuffmanCompressionContext context, + CompressionParameters parameters, + MemorySegment outputBase, + long outputAddress, + int outputSize, + byte[] literals, + int literalsSize) + { + boolean bypassCompression = (parameters.getStrategy() == CompressionParameters.Strategy.FAST) && (parameters.getTargetLength() > 0); + if (bypassCompression || literalsSize <= MINIMUM_LITERALS_SIZE) { + return rawLiterals(outputBase, outputAddress, outputSize, MemorySegment.ofArray(literals), 0, literalsSize); + } + + int headerSize = 3 + (literalsSize >= 1024 ? 1 : 0) + (literalsSize >= 16384 ? 1 : 0); + + checkArgument(headerSize + 1 <= outputSize, "Output buffer too small"); + + int[] counts = new int[MAX_SYMBOL_COUNT]; + Histogram.count(literals, literalsSize, counts); + int maxSymbol = Histogram.findMaxSymbol(counts, MAX_SYMBOL); + int largestCount = Histogram.findLargestCount(counts, maxSymbol); + + MemorySegment literalsBase = MemorySegment.ofArray(literals); + long literalsAddress = 0; + if (largestCount == literalsSize) { + return rleLiterals(outputBase, outputAddress, outputSize, literalsBase, literalsAddress, literalsSize); + } + else if (largestCount <= (literalsSize >>> 7) + 4) { + return rawLiterals(outputBase, outputAddress, outputSize, literalsBase, literalsAddress, literalsSize); + } + + HuffmanCompressionTable previousTable = context.getPreviousTable(); + HuffmanCompressionTable table; + int serializedTableSize; + boolean reuseTable; + + boolean canReuse = previousTable.isValid(counts, maxSymbol); + + boolean preferReuse = parameters.getStrategy().ordinal() < CompressionParameters.Strategy.LAZY.ordinal() && literalsSize <= 1024; + if (preferReuse && canReuse) { + table = previousTable; + reuseTable = true; + serializedTableSize = 0; + } + else { + HuffmanCompressionTable newTable = context.borrowTemporaryTable(); + + newTable.initialize( + counts, + maxSymbol, + HuffmanCompressionTable.optimalNumberOfBits(MAX_HUFFMAN_TABLE_LOG, literalsSize, maxSymbol), + context.getCompressionTableWorkspace()); + + serializedTableSize = newTable.write(outputBase, outputAddress + headerSize, outputSize - headerSize, context.getTableWriterWorkspace()); + + if (canReuse && previousTable.estimateCompressedSize(counts, maxSymbol) <= serializedTableSize + newTable.estimateCompressedSize(counts, maxSymbol)) { + table = previousTable; + reuseTable = true; + serializedTableSize = 0; + context.discardTemporaryTable(); + } + else { + table = newTable; + reuseTable = false; + } + } + + int compressedSize; + boolean singleStream = literalsSize < 256; + if (singleStream) { + compressedSize = HuffmanCompressor.compressSingleStream(outputBase, outputAddress + headerSize + serializedTableSize, outputSize - headerSize - serializedTableSize, literalsBase, literalsAddress, literalsSize, table); + } + else { + compressedSize = HuffmanCompressor.compress4streams(outputBase, outputAddress + headerSize + serializedTableSize, outputSize - headerSize - serializedTableSize, literalsBase, literalsAddress, literalsSize, table); + } + + int totalSize = serializedTableSize + compressedSize; + int minimumGain = calculateMinimumGain(literalsSize, parameters.getStrategy()); + + if (compressedSize == 0 || totalSize >= literalsSize - minimumGain) { + context.discardTemporaryTable(); + + return rawLiterals(outputBase, outputAddress, outputSize, literalsBase, 0, literalsSize); + } + + int encodingType = reuseTable ? TREELESS_LITERALS_BLOCK : COMPRESSED_LITERALS_BLOCK; + + switch (headerSize) { + case 3: { + int header = encodingType | ((singleStream ? 0 : 1) << 2) | (literalsSize << 4) | (totalSize << 14); + put24BitLittleEndian(outputBase, outputAddress, header); + break; + } + case 4: { + int header = encodingType | (2 << 2) | (literalsSize << 4) | (totalSize << 18); + outputBase.set(JAVA_INT, outputAddress, header); + break; + } + case 5: { + int header = encodingType | (3 << 2) | (literalsSize << 4) | (totalSize << 22); + outputBase.set(JAVA_INT, outputAddress, header); + outputBase.set(JAVA_BYTE, outputAddress + SIZE_OF_INT, (byte) (totalSize >>> 10)); + break; + } + default: + throw new IllegalStateException(); + } + + return headerSize + totalSize; + } + + private static int rleLiterals(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize) + { + int headerSize = 1 + (inputSize > 31 ? 1 : 0) + (inputSize > 4095 ? 1 : 0); + + switch (headerSize) { + case 1: + outputBase.set(JAVA_BYTE, outputAddress, (byte) (RLE_LITERALS_BLOCK | (inputSize << 3))); + break; + case 2: + outputBase.set(JAVA_SHORT, outputAddress, (short) (RLE_LITERALS_BLOCK | (1 << 2) | (inputSize << 4))); + break; + case 3: + outputBase.set(JAVA_INT, outputAddress, RLE_LITERALS_BLOCK | 3 << 2 | inputSize << 4); + break; + default: + throw new IllegalStateException(); + } + + outputBase.set(JAVA_BYTE, outputAddress + headerSize, inputBase.get(JAVA_BYTE, inputAddress)); + + return headerSize + 1; + } + + private static int calculateMinimumGain(int inputSize, CompressionParameters.Strategy strategy) + { + int minLog = strategy == CompressionParameters.Strategy.BTULTRA ? 7 : 6; + return (inputSize >>> minLog) + 2; + } + + private static int rawLiterals(MemorySegment outputBase, long outputAddress, int outputSize, MemorySegment inputBase, long inputAddress, int inputSize) + { + int headerSize = 1; + if (inputSize >= 32) { + headerSize++; + } + if (inputSize >= 4096) { + headerSize++; + } + + checkArgument(inputSize + headerSize <= outputSize, "Output buffer too small"); + + switch (headerSize) { + case 1: + outputBase.set(JAVA_BYTE, outputAddress, (byte) (RAW_LITERALS_BLOCK | (inputSize << 3))); + break; + case 2: + outputBase.set(JAVA_SHORT, outputAddress, (short) (RAW_LITERALS_BLOCK | (1 << 2) | (inputSize << 4))); + break; + case 3: + put24BitLittleEndian(outputBase, outputAddress, RAW_LITERALS_BLOCK | (3 << 2) | (inputSize << 4)); + break; + default: + throw new AssertionError(); + } + + checkArgument(inputSize + 1 <= outputSize, "Output buffer too small"); + + MemorySegment.copy(inputBase, inputAddress, outputBase, outputAddress + headerSize, inputSize); + + return headerSize + inputSize; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java new file mode 100644 index 00000000..d7e721b8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdFrameDecompressor.java @@ -0,0 +1,968 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.BitInputStream.peekBits; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.DEFAULT_MAX_OFFSET_CODE_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERALS_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.LITERAL_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.LONG_NUMBER_OF_SEQUENCES; +import static io.airlift.compress.v3.zstdFFM.Constants.MAGIC_NUMBER; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_BITS; +import static io.airlift.compress.v3.zstdFFM.Constants.MATCH_LENGTH_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_LITERALS_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_MATCH_LENGTH_SYMBOL; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_SEQUENCES_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.MIN_WINDOW_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.OFFSET_TABLE_LOG; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_BASIC; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_COMPRESSED; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_REPEAT; +import static io.airlift.compress.v3.zstdFFM.Constants.SEQUENCE_ENCODING_RLE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BYTE; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_SHORT; +import static io.airlift.compress.v3.zstdFFM.Constants.TREELESS_LITERALS_BLOCK; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_LONG; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_SHORT; +import static io.airlift.compress.v3.zstdFFM.Util.fail; +import static io.airlift.compress.v3.zstdFFM.Util.get24BitLittleEndian; +import static io.airlift.compress.v3.zstdFFM.Util.mask; +import static io.airlift.compress.v3.zstdFFM.Util.verify; +import static java.lang.String.format; + +class ZstdFrameDecompressor +{ + private static final int[] DEC_32_TABLE = {4, 1, 2, 1, 4, 4, 4, 4}; + private static final int[] DEC_64_TABLE = {0, 0, 0, -1, 0, 1, 2, 3}; + + private static final int V07_MAGIC_NUMBER = 0xFD2FB527; + + static final int MAX_WINDOW_SIZE = 1 << 23; + + private static final int[] LITERALS_LENGTH_BASE = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 18, 20, 22, 24, 28, 32, 40, 48, 64, 0x80, 0x100, 0x200, 0x400, 0x800, 0x1000, + 0x2000, 0x4000, 0x8000, 0x10000}; + + private static final int[] MATCH_LENGTH_BASE = { + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 37, 39, 41, 43, 47, 51, 59, 67, 83, 99, 0x83, 0x103, 0x203, 0x403, 0x803, + 0x1003, 0x2003, 0x4003, 0x8003, 0x10003}; + + private static final int[] OFFSET_CODES_BASE = { + 0, 1, 1, 5, 0xD, 0x1D, 0x3D, 0x7D, + 0xFD, 0x1FD, 0x3FD, 0x7FD, 0xFFD, 0x1FFD, 0x3FFD, 0x7FFD, + 0xFFFD, 0x1FFFD, 0x3FFFD, 0x7FFFD, 0xFFFFD, 0x1FFFFD, 0x3FFFFD, 0x7FFFFD, + 0xFFFFFD, 0x1FFFFFD, 0x3FFFFFD, 0x7FFFFFD, 0xFFFFFFD}; + + private static final FiniteStateEntropy.Table DEFAULT_LITERALS_LENGTH_TABLE = new FiniteStateEntropy.Table( + 6, + new int[] { + 0, 16, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 32, 0, 0, 32, 0, 32, 0, 32, 0, 0, 32, 0, 32, 0, 32, 0, 0, 16, 32, 0, 0, 48, 16, 32, 32, 32, + 32, 32, 32, 32, 32, 0, 32, 32, 32, 32, 32, 32, 0, 0, 0, 0}, + new byte[] { + 0, 0, 1, 3, 4, 6, 7, 9, 10, 12, 14, 16, 18, 19, 21, 22, 24, 25, 26, 27, 29, 31, 0, 1, 2, 4, 5, 7, 8, 10, 11, 13, 16, 17, 19, 20, 22, 23, 25, 25, 26, 28, 30, 0, + 1, 2, 3, 5, 6, 8, 9, 11, 12, 15, 17, 18, 20, 21, 23, 24, 35, 34, 33, 32}, + new byte[] { + 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 4, 4, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 4, 4, 5, 6, 6, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6}); + + private static final FiniteStateEntropy.Table DEFAULT_OFFSET_CODES_TABLE = new FiniteStateEntropy.Table( + 5, + new int[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 16, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0}, + new byte[] {0, 6, 9, 15, 21, 3, 7, 12, 18, 23, 5, 8, 14, 20, 2, 7, 11, 17, 22, 4, 8, 13, 19, 1, 6, 10, 16, 28, 27, 26, 25, 24}, + new byte[] {5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5}); + + private static final FiniteStateEntropy.Table DEFAULT_MATCH_LENGTH_TABLE = new FiniteStateEntropy.Table( + 6, + new int[] { + 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, 32, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 48, 16, 32, 32, 32, 32, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new byte[] { + 0, 1, 2, 3, 5, 6, 8, 10, 13, 16, 19, 22, 25, 28, 31, 33, 35, 37, 39, 41, 43, 45, 1, 2, 3, 4, 6, 7, 9, 12, 15, 18, 21, 24, 27, 30, 32, 34, 36, 38, 40, 42, 44, 1, + 1, 2, 4, 5, 7, 8, 11, 14, 17, 20, 23, 26, 29, 52, 51, 50, 49, 48, 47, 46}, + new byte[] { + 6, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6}); + + private final byte[] literals = new byte[MAX_BLOCK_SIZE + SIZE_OF_LONG]; + private final MemorySegment literalsSegment = MemorySegment.ofArray(literals); + + private MemorySegment literalsBase; + private long literalsAddress; + private long literalsLimit; + + private final int[] previousOffsets = new int[3]; + + private final FiniteStateEntropy.Table literalsLengthTable = new FiniteStateEntropy.Table(LITERAL_LENGTH_TABLE_LOG); + private final FiniteStateEntropy.Table offsetCodesTable = new FiniteStateEntropy.Table(OFFSET_TABLE_LOG); + private final FiniteStateEntropy.Table matchLengthTable = new FiniteStateEntropy.Table(MATCH_LENGTH_TABLE_LOG); + + private FiniteStateEntropy.Table currentLiteralsLengthTable; + private FiniteStateEntropy.Table currentOffsetCodesTable; + private FiniteStateEntropy.Table currentMatchLengthTable; + + private final Huffman huffman = new Huffman(); + private final FseTableReader fse = new FseTableReader(); + + public int decompress( + final MemorySegment inputBase, + final long inputAddress, + final long inputLimit, + final MemorySegment outputBase, + final long outputAddress, + final long outputLimit) + { + if (outputAddress == outputLimit) { + return 0; + } + + long input = inputAddress; + long output = outputAddress; + + while (input < inputLimit) { + reset(); + long outputStart = output; + input += verifyMagic(inputBase, input, inputLimit); + + FrameHeader frameHeader = readFrameHeader(inputBase, input, inputLimit); + input += frameHeader.headerSize; + + boolean lastBlock; + do { + verify(input + SIZE_OF_BLOCK_HEADER <= inputLimit, input, "Not enough input bytes"); + + int header = get24BitLittleEndian(inputBase, input); + input += SIZE_OF_BLOCK_HEADER; + + lastBlock = (header & 1) != 0; + int blockType = (header >>> 1) & 0b11; + int blockSize = (header >>> 3) & 0x1F_FFFF; + + int decodedSize; + switch (blockType) { + case RAW_BLOCK: + verify(input + blockSize <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeRawBlock(inputBase, input, blockSize, outputBase, output, outputLimit); + input += blockSize; + break; + case RLE_BLOCK: + verify(input + 1 <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeRleBlock(blockSize, inputBase, input, outputBase, output, outputLimit); + input += 1; + break; + case COMPRESSED_BLOCK: + verify(input + blockSize <= inputLimit, input, "Not enough input bytes"); + decodedSize = decodeCompressedBlock(inputBase, input, blockSize, outputBase, output, outputLimit, frameHeader.windowSize, outputAddress); + input += blockSize; + break; + default: + throw fail(input, "Invalid block type"); + } + + output += decodedSize; + } + while (!lastBlock); + + if (frameHeader.hasChecksum) { + int decodedFrameSize = (int) (output - outputStart); + + long hash = XxHash64.hash(0, outputBase, outputStart, decodedFrameSize); + + verify(input + SIZE_OF_INT <= inputLimit, input, "Not enough input bytes"); + int checksum = inputBase.get(JAVA_INT, input); + if (checksum != (int) hash) { + throw new MalformedInputException(input, format("Bad checksum. Expected: %s, actual: %s", Integer.toHexString(checksum), Integer.toHexString((int) hash))); + } + + input += SIZE_OF_INT; + } + } + + return (int) (output - outputAddress); + } + + void reset() + { + previousOffsets[0] = 1; + previousOffsets[1] = 4; + previousOffsets[2] = 8; + + currentLiteralsLengthTable = null; + currentOffsetCodesTable = null; + currentMatchLengthTable = null; + } + + static int decodeRawBlock(MemorySegment inputBase, long inputAddress, int blockSize, MemorySegment outputBase, long outputAddress, long outputLimit) + { + verify(outputAddress + blockSize <= outputLimit, inputAddress, "Output buffer too small"); + + MemorySegment.copy(inputBase, inputAddress, outputBase, outputAddress, blockSize); + return blockSize; + } + + static int decodeRleBlock(int size, MemorySegment inputBase, long inputAddress, MemorySegment outputBase, long outputAddress, long outputLimit) + { + verify(outputAddress + size <= outputLimit, inputAddress, "Output buffer too small"); + + long output = outputAddress; + long value = inputBase.get(JAVA_BYTE, inputAddress) & 0xFFL; + + int remaining = size; + if (remaining >= SIZE_OF_LONG) { + long packed = value + | (value << 8) + | (value << 16) + | (value << 24) + | (value << 32) + | (value << 40) + | (value << 48) + | (value << 56); + + do { + outputBase.set(JAVA_LONG, output, packed); + output += SIZE_OF_LONG; + remaining -= SIZE_OF_LONG; + } + while (remaining >= SIZE_OF_LONG); + } + + for (int i = 0; i < remaining; i++) { + outputBase.set(JAVA_BYTE, output, (byte) value); + output++; + } + + return size; + } + + int decodeCompressedBlock( + MemorySegment inputBase, + final long inputAddress, + int blockSize, + MemorySegment outputBase, + long outputAddress, + long outputLimit, + int windowSize, + long outputAbsoluteBaseAddress) + { + long inputLimit = inputAddress + blockSize; + long input = inputAddress; + + verify(blockSize <= MAX_BLOCK_SIZE, input, "Expected match length table to be present"); + verify(blockSize >= MIN_BLOCK_SIZE, input, "Compressed block size too small"); + + // decode literals + int literalsBlockType = inputBase.get(JAVA_BYTE, input) & 0b11; + + switch (literalsBlockType) { + case RAW_LITERALS_BLOCK: { + input += decodeRawLiterals(inputBase, input, inputLimit); + break; + } + case RLE_LITERALS_BLOCK: { + input += decodeRleLiterals(inputBase, input, blockSize); + break; + } + case TREELESS_LITERALS_BLOCK: + verify(huffman.isLoaded(), input, "Dictionary is corrupted"); + case COMPRESSED_LITERALS_BLOCK: { + input += decodeCompressedLiterals(inputBase, input, blockSize, literalsBlockType); + break; + } + default: + throw fail(input, "Invalid literals block encoding type"); + } + + verify(windowSize <= MAX_WINDOW_SIZE, input, "Window size too large (not yet supported)"); + + return decompressSequences( + inputBase, input, inputAddress + blockSize, + outputBase, outputAddress, outputLimit, + literalsBase, literalsAddress, literalsLimit, + outputAbsoluteBaseAddress); + } + + private int decompressSequences( + final MemorySegment inputBase, final long inputAddress, final long inputLimit, + final MemorySegment outputBase, final long outputAddress, final long outputLimit, + final MemorySegment literalsBase, final long literalsAddress, final long literalsLimit, + long outputAbsoluteBaseAddress) + { + final long fastOutputLimit = outputLimit - SIZE_OF_LONG; + final long fastMatchOutputLimit = fastOutputLimit - SIZE_OF_LONG; + + long input = inputAddress; + long output = outputAddress; + + long literalsInput = literalsAddress; + + int size = (int) (inputLimit - inputAddress); + verify(size >= MIN_SEQUENCES_SIZE, input, "Not enough input bytes"); + + // decode header + int sequenceCount = inputBase.get(JAVA_BYTE, input++) & 0xFF; + if (sequenceCount != 0) { + if (sequenceCount == 255) { + verify(input + SIZE_OF_SHORT <= inputLimit, input, "Not enough input bytes"); + sequenceCount = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) + LONG_NUMBER_OF_SEQUENCES; + input += SIZE_OF_SHORT; + } + else if (sequenceCount > 127) { + verify(input < inputLimit, input, "Not enough input bytes"); + sequenceCount = ((sequenceCount - 128) << 8) + (inputBase.get(JAVA_BYTE, input++) & 0xFF); + } + + verify(input + SIZE_OF_INT <= inputLimit, input, "Not enough input bytes"); + + byte type = inputBase.get(JAVA_BYTE, input++); + + int literalsLengthType = (type & 0xFF) >>> 6; + int offsetCodesType = (type >>> 4) & 0b11; + int matchLengthType = (type >>> 2) & 0b11; + + input = computeLiteralsTable(literalsLengthType, inputBase, input, inputLimit); + input = computeOffsetsTable(offsetCodesType, inputBase, input, inputLimit); + input = computeMatchLengthTable(matchLengthType, inputBase, input, inputLimit); + + // decompress sequences + BitInputStream.Initializer initializer = new BitInputStream.Initializer(inputBase, input, inputLimit); + initializer.initialize(); + int bitsConsumed = initializer.getBitsConsumed(); + long bits = initializer.getBits(); + long currentAddress = initializer.getCurrentAddress(); + + FiniteStateEntropy.Table currentLiteralsLengthTable = this.currentLiteralsLengthTable; + FiniteStateEntropy.Table currentOffsetCodesTable = this.currentOffsetCodesTable; + FiniteStateEntropy.Table currentMatchLengthTable = this.currentMatchLengthTable; + + int literalsLengthState = (int) peekBits(bitsConsumed, bits, currentLiteralsLengthTable.log2Size); + bitsConsumed += currentLiteralsLengthTable.log2Size; + + int offsetCodesState = (int) peekBits(bitsConsumed, bits, currentOffsetCodesTable.log2Size); + bitsConsumed += currentOffsetCodesTable.log2Size; + + int matchLengthState = (int) peekBits(bitsConsumed, bits, currentMatchLengthTable.log2Size); + bitsConsumed += currentMatchLengthTable.log2Size; + + int[] previousOffsets = this.previousOffsets; + + byte[] literalsLengthNumbersOfBits = currentLiteralsLengthTable.numberOfBits; + int[] literalsLengthNewStates = currentLiteralsLengthTable.newState; + byte[] literalsLengthSymbols = currentLiteralsLengthTable.symbol; + + byte[] matchLengthNumbersOfBits = currentMatchLengthTable.numberOfBits; + int[] matchLengthNewStates = currentMatchLengthTable.newState; + byte[] matchLengthSymbols = currentMatchLengthTable.symbol; + + byte[] offsetCodesNumbersOfBits = currentOffsetCodesTable.numberOfBits; + int[] offsetCodesNewStates = currentOffsetCodesTable.newState; + byte[] offsetCodesSymbols = currentOffsetCodesTable.symbol; + + while (sequenceCount > 0) { + sequenceCount--; + + BitInputStream.Loader loader = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader.load(); + bitsConsumed = loader.getBitsConsumed(); + bits = loader.getBits(); + currentAddress = loader.getCurrentAddress(); + if (loader.isOverflow()) { + verify(sequenceCount == 0, input, "Not all sequences were consumed"); + break; + } + + // decode sequence + int literalsLengthCode = literalsLengthSymbols[literalsLengthState]; + int matchLengthCode = matchLengthSymbols[matchLengthState]; + int offsetCode = offsetCodesSymbols[offsetCodesState]; + + int literalsLengthBits = LITERALS_LENGTH_BITS[literalsLengthCode]; + int matchLengthBits = MATCH_LENGTH_BITS[matchLengthCode]; + int offsetBits = offsetCode; + + int offset = OFFSET_CODES_BASE[offsetCode]; + if (offsetCode > 0) { + offset += peekBits(bitsConsumed, bits, offsetBits); + bitsConsumed += offsetBits; + } + + if (offsetCode <= 1) { + if (literalsLengthCode == 0) { + offset++; + } + + if (offset != 0) { + int temp; + if (offset == 3) { + temp = previousOffsets[0] - 1; + } + else { + temp = previousOffsets[offset]; + } + + if (temp == 0) { + temp = 1; + } + + if (offset != 1) { + previousOffsets[2] = previousOffsets[1]; + } + previousOffsets[1] = previousOffsets[0]; + previousOffsets[0] = temp; + + offset = temp; + } + else { + offset = previousOffsets[0]; + } + } + else { + previousOffsets[2] = previousOffsets[1]; + previousOffsets[1] = previousOffsets[0]; + previousOffsets[0] = offset; + } + + int matchLength = MATCH_LENGTH_BASE[matchLengthCode]; + if (matchLengthCode > 31) { + matchLength += peekBits(bitsConsumed, bits, matchLengthBits); + bitsConsumed += matchLengthBits; + } + + int literalsLength = LITERALS_LENGTH_BASE[literalsLengthCode]; + if (literalsLengthCode > 15) { + literalsLength += peekBits(bitsConsumed, bits, literalsLengthBits); + bitsConsumed += literalsLengthBits; + } + + int totalBits = literalsLengthBits + matchLengthBits + offsetBits; + if (totalBits > 64 - 7 - (LITERAL_LENGTH_TABLE_LOG + MATCH_LENGTH_TABLE_LOG + OFFSET_TABLE_LOG)) { + BitInputStream.Loader loader1 = new BitInputStream.Loader(inputBase, input, currentAddress, bits, bitsConsumed); + loader1.load(); + + bitsConsumed = loader1.getBitsConsumed(); + bits = loader1.getBits(); + currentAddress = loader1.getCurrentAddress(); + } + + int numberOfBits; + + numberOfBits = literalsLengthNumbersOfBits[literalsLengthState]; + literalsLengthState = (int) (literalsLengthNewStates[literalsLengthState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + numberOfBits = matchLengthNumbersOfBits[matchLengthState]; + matchLengthState = (int) (matchLengthNewStates[matchLengthState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + numberOfBits = offsetCodesNumbersOfBits[offsetCodesState]; + offsetCodesState = (int) (offsetCodesNewStates[offsetCodesState] + peekBits(bitsConsumed, bits, numberOfBits)); + bitsConsumed += numberOfBits; + + final long literalOutputLimit = output + literalsLength; + final long matchOutputLimit = literalOutputLimit + matchLength; + + verify(matchOutputLimit <= outputLimit, input, "Output buffer too small"); + long literalEnd = literalsInput + literalsLength; + verify(literalEnd <= literalsLimit, input, "Input is corrupted"); + + long matchAddress = literalOutputLimit - offset; + verify(matchAddress >= outputAbsoluteBaseAddress, input, "Input is corrupted"); + + if (literalOutputLimit > fastOutputLimit) { + executeLastSequence(outputBase, output, literalOutputLimit, matchOutputLimit, fastOutputLimit, literalsInput, matchAddress); + } + else { + output = copyLiterals(outputBase, literalsBase, output, literalsInput, literalOutputLimit); + copyMatch(outputBase, fastOutputLimit, output, offset, matchOutputLimit, matchAddress, matchLength, fastMatchOutputLimit); + } + output = matchOutputLimit; + literalsInput = literalEnd; + } + } + + // last literal segment + output = copyLastLiteral(input, literalsBase, literalsInput, literalsLimit, outputBase, output, outputLimit); + + return (int) (output - outputAddress); + } + + private static long copyLastLiteral(long input, MemorySegment literalsBase, long literalsInput, long literalsLimit, MemorySegment outputBase, long output, long outputLimit) + { + long lastLiteralsSize = literalsLimit - literalsInput; + verify(output + lastLiteralsSize <= outputLimit, input, "Output buffer too small"); + MemorySegment.copy(literalsBase, literalsInput, outputBase, output, lastLiteralsSize); + output += lastLiteralsSize; + return output; + } + + private static void copyMatch(MemorySegment outputBase, + long fastOutputLimit, + long output, + int offset, + long matchOutputLimit, + long matchAddress, + int matchLength, + long fastMatchOutputLimit) + { + matchAddress = copyMatchHead(outputBase, output, offset, matchAddress); + output += SIZE_OF_LONG; + matchLength -= SIZE_OF_LONG; + + copyMatchTail(outputBase, fastOutputLimit, output, matchOutputLimit, matchAddress, matchLength, fastMatchOutputLimit); + } + + private static void copyMatchTail(MemorySegment outputBase, long fastOutputLimit, long output, long matchOutputLimit, long matchAddress, int matchLength, long fastMatchOutputLimit) + { + if (matchOutputLimit < fastMatchOutputLimit) { + int copied = 0; + do { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + output += SIZE_OF_LONG; + matchAddress += SIZE_OF_LONG; + copied += SIZE_OF_LONG; + } + while (copied < matchLength); + } + else { + while (output < fastOutputLimit) { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + matchAddress += SIZE_OF_LONG; + output += SIZE_OF_LONG; + } + + while (output < matchOutputLimit) { + outputBase.set(JAVA_BYTE, output++, outputBase.get(JAVA_BYTE, matchAddress++)); + } + } + } + + private static long copyMatchHead(MemorySegment outputBase, long output, int offset, long matchAddress) + { + if (offset < 8) { + int increment32 = DEC_32_TABLE[offset]; + int decrement64 = DEC_64_TABLE[offset]; + + outputBase.set(JAVA_BYTE, output, outputBase.get(JAVA_BYTE, matchAddress)); + outputBase.set(JAVA_BYTE, output + 1, outputBase.get(JAVA_BYTE, matchAddress + 1)); + outputBase.set(JAVA_BYTE, output + 2, outputBase.get(JAVA_BYTE, matchAddress + 2)); + outputBase.set(JAVA_BYTE, output + 3, outputBase.get(JAVA_BYTE, matchAddress + 3)); + matchAddress += increment32; + + outputBase.set(JAVA_INT, output + 4, outputBase.get(JAVA_INT, matchAddress)); + matchAddress -= decrement64; + } + else { + outputBase.set(JAVA_LONG, output, outputBase.get(JAVA_LONG, matchAddress)); + matchAddress += SIZE_OF_LONG; + } + return matchAddress; + } + + private static long copyLiterals(MemorySegment outputBase, MemorySegment literalsBase, long output, long literalsInput, long literalOutputLimit) + { + long literalInput = literalsInput; + do { + outputBase.set(JAVA_LONG, output, literalsBase.get(JAVA_LONG, literalInput)); + output += SIZE_OF_LONG; + literalInput += SIZE_OF_LONG; + } + while (output < literalOutputLimit); + output = literalOutputLimit; + return output; + } + + private long computeMatchLengthTable(int matchLengthType, MemorySegment inputBase, long input, long inputLimit) + { + switch (matchLengthType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= MAX_MATCH_LENGTH_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(matchLengthTable, value); + currentMatchLengthTable = matchLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentMatchLengthTable = DEFAULT_MATCH_LENGTH_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentMatchLengthTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(matchLengthTable, inputBase, input, inputLimit, MAX_MATCH_LENGTH_SYMBOL, MATCH_LENGTH_TABLE_LOG); + currentMatchLengthTable = matchLengthTable; + break; + default: + throw fail(input, "Invalid match length encoding type"); + } + return input; + } + + private long computeOffsetsTable(int offsetCodesType, MemorySegment inputBase, long input, long inputLimit) + { + switch (offsetCodesType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= DEFAULT_MAX_OFFSET_CODE_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(offsetCodesTable, value); + currentOffsetCodesTable = offsetCodesTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentOffsetCodesTable = DEFAULT_OFFSET_CODES_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentOffsetCodesTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(offsetCodesTable, inputBase, input, inputLimit, DEFAULT_MAX_OFFSET_CODE_SYMBOL, OFFSET_TABLE_LOG); + currentOffsetCodesTable = offsetCodesTable; + break; + default: + throw fail(input, "Invalid offset code encoding type"); + } + return input; + } + + private long computeLiteralsTable(int literalsLengthType, MemorySegment inputBase, long input, long inputLimit) + { + switch (literalsLengthType) { + case SEQUENCE_ENCODING_RLE: + verify(input < inputLimit, input, "Not enough input bytes"); + + byte value = inputBase.get(JAVA_BYTE, input++); + verify(value <= MAX_LITERALS_LENGTH_SYMBOL, input, "Value exceeds expected maximum value"); + + FseTableReader.initializeRleTable(literalsLengthTable, value); + currentLiteralsLengthTable = literalsLengthTable; + break; + case SEQUENCE_ENCODING_BASIC: + currentLiteralsLengthTable = DEFAULT_LITERALS_LENGTH_TABLE; + break; + case SEQUENCE_ENCODING_REPEAT: + verify(currentLiteralsLengthTable != null, input, "Expected match length table to be present"); + break; + case SEQUENCE_ENCODING_COMPRESSED: + input += fse.readFseTable(literalsLengthTable, inputBase, input, inputLimit, MAX_LITERALS_LENGTH_SYMBOL, LITERAL_LENGTH_TABLE_LOG); + currentLiteralsLengthTable = literalsLengthTable; + break; + default: + throw fail(input, "Invalid literals length encoding type"); + } + return input; + } + + private void executeLastSequence(MemorySegment outputBase, long output, long literalOutputLimit, long matchOutputLimit, long fastOutputLimit, long literalInput, long matchAddress) + { + // copy literals + if (output < fastOutputLimit) { + do { + outputBase.set(JAVA_LONG, output, literalsBase.get(JAVA_LONG, literalInput)); + output += SIZE_OF_LONG; + literalInput += SIZE_OF_LONG; + } + while (output < fastOutputLimit); + + literalInput -= output - fastOutputLimit; + output = fastOutputLimit; + } + + while (output < literalOutputLimit) { + outputBase.set(JAVA_BYTE, output, literalsBase.get(JAVA_BYTE, literalInput)); + output++; + literalInput++; + } + + // copy match + while (output < matchOutputLimit) { + outputBase.set(JAVA_BYTE, output, outputBase.get(JAVA_BYTE, matchAddress)); + output++; + matchAddress++; + } + } + + private int decodeCompressedLiterals(MemorySegment inputBase, final long inputAddress, int blockSize, int literalsBlockType) + { + long input = inputAddress; + verify(blockSize >= 5, input, "Not enough input bytes"); + + int compressedSize; + int uncompressedSize; + boolean singleStream = false; + int headerSize; + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + switch (type) { + case 0: + singleStream = true; + case 1: { + int header = inputBase.get(JAVA_INT, input); + + headerSize = 3; + uncompressedSize = (header >>> 4) & mask(10); + compressedSize = (header >>> 14) & mask(10); + break; + } + case 2: { + int header = inputBase.get(JAVA_INT, input); + + headerSize = 4; + uncompressedSize = (header >>> 4) & mask(14); + compressedSize = (header >>> 18) & mask(14); + break; + } + case 3: { + long header = inputBase.get(JAVA_BYTE, input) & 0xFF | + (inputBase.get(JAVA_INT, input + 1) & 0xFFFF_FFFFL) << 8; + + headerSize = 5; + uncompressedSize = (int) ((header >>> 4) & mask(18)); + compressedSize = (int) ((header >>> 22) & mask(18)); + break; + } + default: + throw fail(input, "Invalid literals header size type"); + } + + verify(uncompressedSize <= MAX_BLOCK_SIZE, input, "Block exceeds maximum size"); + verify(headerSize + compressedSize <= blockSize, input, "Input is corrupted"); + + input += headerSize; + + long inputLimit = input + compressedSize; + if (literalsBlockType != TREELESS_LITERALS_BLOCK) { + input += huffman.readTable(inputBase, input, compressedSize); + } + + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = uncompressedSize; + + if (singleStream) { + huffman.decodeSingleStream(inputBase, input, inputLimit, literalsSegment, literalsAddress, literalsLimit); + } + else { + huffman.decode4Streams(inputBase, input, inputLimit, literalsSegment, literalsAddress, literalsLimit); + } + + return headerSize + compressedSize; + } + + private int decodeRleLiterals(MemorySegment inputBase, final long inputAddress, int blockSize) + { + long input = inputAddress; + int outputSize; + + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + switch (type) { + case 0: + case 2: + outputSize = (inputBase.get(JAVA_BYTE, input) & 0xFF) >>> 3; + input++; + break; + case 1: + outputSize = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) >>> 4; + input += 2; + break; + case 3: + verify(blockSize >= SIZE_OF_INT, input, "Not enough input bytes"); + outputSize = (inputBase.get(JAVA_INT, input) & 0xFF_FFFF) >>> 4; + input += 3; + break; + default: + throw fail(input, "Invalid RLE literals header encoding type"); + } + + verify(outputSize <= MAX_BLOCK_SIZE, input, "Output exceeds maximum block size"); + + byte value = inputBase.get(JAVA_BYTE, input++); + Arrays.fill(literals, 0, outputSize + SIZE_OF_LONG, value); + + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = outputSize; + + return (int) (input - inputAddress); + } + + private int decodeRawLiterals(MemorySegment inputBase, final long inputAddress, long inputLimit) + { + long input = inputAddress; + int type = (inputBase.get(JAVA_BYTE, input) >> 2) & 0b11; + + int literalSize; + switch (type) { + case 0: + case 2: + literalSize = (inputBase.get(JAVA_BYTE, input) & 0xFF) >>> 3; + input++; + break; + case 1: + literalSize = (inputBase.get(JAVA_SHORT, input) & 0xFFFF) >>> 4; + input += 2; + break; + case 3: + int header = ((inputBase.get(JAVA_BYTE, input) & 0xFF) | + ((inputBase.get(JAVA_SHORT, input + 1) & 0xFFFF) << 8)); + + literalSize = header >>> 4; + input += 3; + break; + default: + throw fail(input, "Invalid raw literals header encoding type"); + } + + verify(input + literalSize <= inputLimit, input, "Not enough input bytes"); + + if (literalSize > (inputLimit - input) - SIZE_OF_LONG) { + literalsBase = literalsSegment; + literalsAddress = 0; + literalsLimit = literalSize; + + MemorySegment.copy(inputBase, input, literalsSegment, literalsAddress, literalSize); + Arrays.fill(literals, literalSize, literalSize + SIZE_OF_LONG, (byte) 0); + } + else { + literalsBase = inputBase; + literalsAddress = input; + literalsLimit = literalsAddress + literalSize; + } + input += literalSize; + + return (int) (input - inputAddress); + } + + static FrameHeader readFrameHeader(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + long input = inputAddress; + verify(input < inputLimit, input, "Not enough input bytes"); + + int frameHeaderDescriptor = inputBase.get(JAVA_BYTE, input++) & 0xFF; + boolean singleSegment = (frameHeaderDescriptor & 0b100000) != 0; + int dictionaryDescriptor = frameHeaderDescriptor & 0b11; + int contentSizeDescriptor = frameHeaderDescriptor >>> 6; + + int headerSize = 1 + + (singleSegment ? 0 : 1) + + (dictionaryDescriptor == 0 ? 0 : (1 << (dictionaryDescriptor - 1))) + + (contentSizeDescriptor == 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeDescriptor)); + + verify(headerSize <= inputLimit - inputAddress, input, "Not enough input bytes"); + + // decode window size + int windowSize = -1; + if (!singleSegment) { + int windowDescriptor = inputBase.get(JAVA_BYTE, input++) & 0xFF; + int exponent = windowDescriptor >>> 3; + int mantissa = windowDescriptor & 0b111; + + int base = 1 << (MIN_WINDOW_LOG + exponent); + windowSize = base + (base / 8) * mantissa; + } + + // decode dictionary id + long dictionaryId = -1; + switch (dictionaryDescriptor) { + case 1: + dictionaryId = inputBase.get(JAVA_BYTE, input) & 0xFF; + input += SIZE_OF_BYTE; + break; + case 2: + dictionaryId = inputBase.get(JAVA_SHORT, input) & 0xFFFF; + input += SIZE_OF_SHORT; + break; + case 3: + dictionaryId = inputBase.get(JAVA_INT, input) & 0xFFFF_FFFFL; + input += SIZE_OF_INT; + break; + } + verify(dictionaryId == -1, input, "Custom dictionaries not supported"); + + // decode content size + long contentSize = -1; + switch (contentSizeDescriptor) { + case 0: + if (singleSegment) { + contentSize = inputBase.get(JAVA_BYTE, input) & 0xFF; + input += SIZE_OF_BYTE; + } + break; + case 1: + contentSize = inputBase.get(JAVA_SHORT, input) & 0xFFFF; + contentSize += 256; + input += SIZE_OF_SHORT; + break; + case 2: + contentSize = inputBase.get(JAVA_INT, input) & 0xFFFF_FFFFL; + input += SIZE_OF_INT; + break; + case 3: + contentSize = inputBase.get(JAVA_LONG, input); + input += SIZE_OF_LONG; + break; + } + + boolean hasChecksum = (frameHeaderDescriptor & 0b100) != 0; + + return new FrameHeader( + input - inputAddress, + windowSize, + contentSize, + dictionaryId, + hasChecksum); + } + + public static long getDecompressedSize(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + long input = inputAddress; + input += verifyMagic(inputBase, input, inputLimit); + return readFrameHeader(inputBase, input, inputLimit).contentSize; + } + + static int verifyMagic(MemorySegment inputBase, long inputAddress, long inputLimit) + { + verify(inputLimit - inputAddress >= 4, inputAddress, "Not enough input bytes"); + + int magic = inputBase.get(JAVA_INT, inputAddress); + if (magic != MAGIC_NUMBER) { + if (magic == V07_MAGIC_NUMBER) { + throw new MalformedInputException(inputAddress, "Data encoded in unsupported ZSTD v0.7 format"); + } + throw new MalformedInputException(inputAddress, "Invalid magic prefix: " + Integer.toHexString(magic)); + } + + return SIZE_OF_INT; + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java new file mode 100644 index 00000000..163b51ea --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopInputStream.java @@ -0,0 +1,68 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopInputStream; + +import java.io.IOException; +import java.io.InputStream; + +import static java.util.Objects.requireNonNull; + +class ZstdHadoopInputStream + extends HadoopInputStream +{ + private final InputStream in; + private ZstdInputStream zstdInputStream; + + public ZstdHadoopInputStream(InputStream in) + { + this.in = requireNonNull(in, "in is null"); + zstdInputStream = new ZstdInputStream(in); + } + + @Override + public int read() + throws IOException + { + return zstdInputStream.read(); + } + + @Override + public int read(byte[] b) + throws IOException + { + return zstdInputStream.read(b); + } + + @Override + public int read(byte[] outputBuffer, int outputOffset, int outputLength) + throws IOException + { + return zstdInputStream.read(outputBuffer, outputOffset, outputLength); + } + + @Override + public void resetState() + { + zstdInputStream = new ZstdInputStream(in); + } + + @Override + public void close() + throws IOException + { + zstdInputStream.close(); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java new file mode 100644 index 00000000..3f4b8f88 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopOutputStream.java @@ -0,0 +1,91 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopOutputStream; + +import java.io.IOException; +import java.io.OutputStream; + +import static java.util.Objects.requireNonNull; + +class ZstdHadoopOutputStream + extends HadoopOutputStream +{ + private final OutputStream out; + private boolean initialized; + private ZstdOutputStream zstdOutputStream; + + public ZstdHadoopOutputStream(OutputStream out) + { + this.out = requireNonNull(out, "out is null"); + } + + @Override + public void write(int b) + throws IOException + { + openStreamIfNecessary(); + zstdOutputStream.write(b); + } + + @Override + public void write(byte[] buffer, int offset, int length) + throws IOException + { + openStreamIfNecessary(); + zstdOutputStream.write(buffer, offset, length); + } + + @Override + public void finish() + throws IOException + { + if (zstdOutputStream != null) { + zstdOutputStream.finishWithoutClosingSource(); + zstdOutputStream = null; + } + } + + @Override + public void flush() + throws IOException + { + out.flush(); + } + + @Override + public void close() + throws IOException + { + try { + if (!initialized) { + openStreamIfNecessary(); + } + finish(); + } + finally { + out.close(); + } + } + + private void openStreamIfNecessary() + throws IOException + { + if (zstdOutputStream == null) { + initialized = true; + zstdOutputStream = new ZstdOutputStream(out); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java new file mode 100644 index 00000000..4aa416bb --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdHadoopStreams.java @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.hadoop.HadoopInputStream; +import io.airlift.compress.v3.hadoop.HadoopOutputStream; +import io.airlift.compress.v3.hadoop.HadoopStreams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +import static java.util.Collections.singletonList; + +public class ZstdHadoopStreams + implements HadoopStreams +{ + @Override + public String getDefaultFileExtension() + { + return ".zst"; + } + + @Override + public List getHadoopCodecName() + { + return singletonList("org.apache.hadoop.io.compress.ZStandardCodec"); + } + + @Override + public HadoopInputStream createInputStream(InputStream in) + throws IOException + { + return new ZstdHadoopInputStream(in); + } + + @Override + public HadoopOutputStream createOutputStream(OutputStream out) + throws IOException + { + return new ZstdHadoopOutputStream(out); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java new file mode 100644 index 00000000..4a899d28 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdIncrementalFrameDecompressor.java @@ -0,0 +1,380 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.Constants.COMPRESSED_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static io.airlift.compress.v3.zstdFFM.Constants.RAW_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.RLE_BLOCK; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_INT; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_BYTE; +import static io.airlift.compress.v3.zstdFFM.FfmUtil.JAVA_INT; +import static io.airlift.compress.v3.zstdFFM.Util.checkArgument; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static io.airlift.compress.v3.zstdFFM.Util.fail; +import static io.airlift.compress.v3.zstdFFM.Util.verify; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.MAX_WINDOW_SIZE; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.decodeRawBlock; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.decodeRleBlock; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.readFrameHeader; +import static io.airlift.compress.v3.zstdFFM.ZstdFrameDecompressor.verifyMagic; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.lang.Math.toIntExact; +import static java.lang.String.format; + +public class ZstdIncrementalFrameDecompressor +{ + private enum State { + INITIAL, + READ_FRAME_MAGIC, + READ_FRAME_HEADER, + READ_BLOCK_HEADER, + READ_BLOCK, + READ_BLOCK_CHECKSUM, + FLUSH_OUTPUT + } + + private final ZstdFrameDecompressor frameDecompressor = new ZstdFrameDecompressor(); + + private State state = State.INITIAL; + private FrameHeader frameHeader; + private int blockHeader = -1; + + private int inputConsumed; + private int outputBufferUsed; + + private int inputRequired; + private int requestedOutputSize; + + // current window buffer + private byte[] windowBase = new byte[0]; + private MemorySegment windowSegment = MemorySegment.ofArray(windowBase); + private long windowAddress = 0; + private long windowLimit = 0; + private long windowPosition = 0; + + private XxHash64 partialHash; + + public boolean isAtStoppingPoint() + { + return state == State.READ_FRAME_MAGIC; + } + + public int getInputConsumed() + { + return inputConsumed; + } + + public int getOutputBufferUsed() + { + return outputBufferUsed; + } + + public int getInputRequired() + { + return inputRequired; + } + + public int getRequestedOutputSize() + { + return requestedOutputSize; + } + + public void partialDecompress( + final MemorySegment inputBase, + final long inputAddress, + final long inputLimit, + final byte[] outputArray, + final int outputOffset, + final int outputLimit) + { + if (inputRequired > inputLimit - inputAddress) { + throw new IllegalArgumentException(format( + "Required %s input bytes, but only %s input bytes were supplied", + inputRequired, + inputLimit - inputAddress)); + } + if (requestedOutputSize > 0 && outputOffset >= outputLimit) { + throw new IllegalArgumentException("Not enough space in output buffer to output"); + } + + long input = inputAddress; + int output = outputOffset; + + while (true) { + // Flush ready output + { + int flushableOutputSize = computeFlushableOutputSize(frameHeader); + if (flushableOutputSize > 0) { + int freeOutputSize = outputLimit - output; + if (freeOutputSize > 0) { + int copySize = min(freeOutputSize, flushableOutputSize); + System.arraycopy(windowBase, toIntExact(windowAddress), outputArray, output, copySize); + if (partialHash != null) { + partialHash.update(outputArray, output, copySize); + } + windowAddress += copySize; + output += copySize; + flushableOutputSize -= copySize; + } + if (flushableOutputSize > 0) { + requestOutput(inputAddress, outputOffset, input, output, flushableOutputSize); + return; + } + } + } + // verify data was completely flushed + checkState(computeFlushableOutputSize(frameHeader) == 0, "Expected output to be flushed"); + + if (state == State.READ_FRAME_MAGIC || state == State.INITIAL) { + if (inputLimit - input < 4) { + inputRequired(inputAddress, outputOffset, input, output, 4); + return; + } + input += verifyMagic(inputBase, input, inputLimit); + state = State.READ_FRAME_HEADER; + } + + if (state == State.READ_FRAME_HEADER) { + if (inputLimit - input < 1) { + inputRequired(inputAddress, outputOffset, input, output, 1); + return; + } + int frameHeaderSize = determineFrameHeaderSize(inputBase, input, inputLimit); + if (inputLimit - input < frameHeaderSize) { + inputRequired(inputAddress, outputOffset, input, output, frameHeaderSize); + return; + } + frameHeader = readFrameHeader(inputBase, input, inputLimit); + verify(frameHeaderSize == frameHeader.headerSize, input, "Unexpected frame header size"); + input += frameHeaderSize; + state = State.READ_BLOCK_HEADER; + + reset(); + if (frameHeader.hasChecksum) { + partialHash = new XxHash64(); + } + } + else { + verify(frameHeader != null, input, "Frame header is not set"); + } + + if (state == State.READ_BLOCK_HEADER) { + long inputBufferSize = inputLimit - input; + if (inputBufferSize < SIZE_OF_BLOCK_HEADER) { + inputRequired(inputAddress, outputOffset, input, output, SIZE_OF_BLOCK_HEADER); + return; + } + if (inputBufferSize >= SIZE_OF_INT) { + blockHeader = inputBase.get(JAVA_INT, input) & 0xFF_FFFF; + } + else { + // FFM enforces bounds, so we cannot read past the buffer end like the Unsafe variant + // (which relied on the JVM's array padding to silently return zero for the unread byte). + blockHeader = inputBase.get(JAVA_BYTE, input) & 0xFF | + (inputBase.get(JAVA_BYTE, input + 1) & 0xFF) << 8 | + (inputBase.get(JAVA_BYTE, input + 2) & 0xFF) << 16; + } + input += SIZE_OF_BLOCK_HEADER; + state = State.READ_BLOCK; + } + else { + verify(blockHeader != -1, input, "Block header is not set"); + } + + boolean lastBlock = (blockHeader & 1) != 0; + if (state == State.READ_BLOCK) { + int blockType = (blockHeader >>> 1) & 0b11; + int blockSize = (blockHeader >>> 3) & 0x1F_FFFF; + + resizeWindowBufferIfNecessary(frameHeader, blockType, blockSize); + + int decodedSize; + switch (blockType) { + case RAW_BLOCK: { + if (inputLimit - input < blockSize) { + inputRequired(inputAddress, outputOffset, input, output, blockSize); + return; + } + verify(windowLimit - windowPosition >= blockSize, input, "window buffer is too small"); + decodedSize = decodeRawBlock(inputBase, input, blockSize, windowSegment, windowPosition, windowLimit); + input += blockSize; + break; + } + case RLE_BLOCK: { + if (inputLimit - input < 1) { + inputRequired(inputAddress, outputOffset, input, output, 1); + return; + } + verify(windowLimit - windowPosition >= blockSize, input, "window buffer is too small"); + decodedSize = decodeRleBlock(blockSize, inputBase, input, windowSegment, windowPosition, windowLimit); + input += 1; + break; + } + case COMPRESSED_BLOCK: { + if (inputLimit - input < blockSize) { + inputRequired(inputAddress, outputOffset, input, output, blockSize); + return; + } + verify(windowLimit - windowPosition >= MAX_BLOCK_SIZE, input, "window buffer is too small"); + decodedSize = frameDecompressor.decodeCompressedBlock(inputBase, input, blockSize, windowSegment, windowPosition, windowLimit, frameHeader.windowSize, windowAddress); + input += blockSize; + break; + } + default: + throw fail(input, "Invalid block type"); + } + windowPosition += decodedSize; + if (lastBlock) { + state = State.READ_BLOCK_CHECKSUM; + } + else { + state = State.READ_BLOCK_HEADER; + } + } + + if (state == State.READ_BLOCK_CHECKSUM) { + if (frameHeader.hasChecksum) { + if (inputLimit - input < SIZE_OF_INT) { + inputRequired(inputAddress, outputOffset, input, output, SIZE_OF_INT); + return; + } + + int checksum = inputBase.get(JAVA_INT, input); + input += SIZE_OF_INT; + + checkState(partialHash != null, "Partial hash not set"); + + int pendingOutputSize = toIntExact(windowPosition - windowAddress); + partialHash.update(windowBase, toIntExact(windowAddress), pendingOutputSize); + + long hash = partialHash.hash(); + if (checksum != (int) hash) { + throw new MalformedInputException(input, format("Bad checksum. Expected: %s, actual: %s", Integer.toHexString(checksum), Integer.toHexString((int) hash))); + } + } + state = State.READ_FRAME_MAGIC; + frameHeader = null; + blockHeader = -1; + } + } + } + + private void reset() + { + frameDecompressor.reset(); + + windowAddress = 0; + windowPosition = 0; + } + + private int computeFlushableOutputSize(FrameHeader frameHeader) + { + return max(0, toIntExact(windowPosition - windowAddress - (frameHeader == null ? 0 : frameHeader.computeRequiredOutputBufferLookBackSize()))); + } + + private void resizeWindowBufferIfNecessary(FrameHeader frameHeader, int blockType, int blockSize) + { + int maxBlockOutput; + if (blockType == RAW_BLOCK || blockType == RLE_BLOCK) { + maxBlockOutput = blockSize; + } + else { + maxBlockOutput = MAX_BLOCK_SIZE; + } + + if (windowLimit - windowPosition < MAX_BLOCK_SIZE) { + int requiredWindowSize = frameHeader.computeRequiredOutputBufferLookBackSize(); + checkState(windowPosition - windowAddress <= requiredWindowSize, "Expected output to be flushed"); + + int windowContentsSize = toIntExact(windowPosition - windowAddress); + + if (windowAddress != 0) { + System.arraycopy(windowBase, toIntExact(windowAddress), windowBase, 0, windowContentsSize); + windowAddress = 0; + windowPosition = windowAddress + windowContentsSize; + } + checkState(windowAddress == 0, "Window should be packed"); + + if (windowLimit - windowPosition < maxBlockOutput) { + int newWindowSize; + if (frameHeader.contentSize >= 0 && frameHeader.contentSize < requiredWindowSize) { + newWindowSize = toIntExact(frameHeader.contentSize); + } + else { + newWindowSize = (windowContentsSize + maxBlockOutput) * 2; + newWindowSize = min(newWindowSize, max(requiredWindowSize, MAX_BLOCK_SIZE) * 4); + newWindowSize = min(newWindowSize, MAX_WINDOW_SIZE + MAX_BLOCK_SIZE); + newWindowSize = max(windowContentsSize + maxBlockOutput, newWindowSize); + checkState(windowContentsSize + maxBlockOutput <= newWindowSize, "Computed new window size buffer is not large enough"); + } + windowBase = Arrays.copyOf(windowBase, newWindowSize); + windowSegment = MemorySegment.ofArray(windowBase); + windowLimit = newWindowSize; + } + + checkState(windowLimit - windowPosition >= maxBlockOutput, "window buffer is too small"); + } + } + + private static int determineFrameHeaderSize(final MemorySegment inputBase, final long inputAddress, final long inputLimit) + { + verify(inputAddress < inputLimit, inputAddress, "Not enough input bytes"); + + int frameHeaderDescriptor = inputBase.get(JAVA_BYTE, inputAddress) & 0xFF; + boolean singleSegment = (frameHeaderDescriptor & 0b100000) != 0; + int dictionaryDescriptor = frameHeaderDescriptor & 0b11; + int contentSizeDescriptor = frameHeaderDescriptor >>> 6; + + return 1 + + (singleSegment ? 0 : 1) + + (dictionaryDescriptor == 0 ? 0 : (1 << (dictionaryDescriptor - 1))) + + (contentSizeDescriptor == 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeDescriptor)); + } + + private void requestOutput(long inputAddress, int outputOffset, long input, int output, int requestedOutputSize) + { + updateInputOutputState(inputAddress, outputOffset, input, output); + + checkArgument(requestedOutputSize >= 0, "requestedOutputSize is negative"); + this.requestedOutputSize = requestedOutputSize; + + this.inputRequired = 0; + } + + private void inputRequired(long inputAddress, int outputOffset, long input, int output, int inputRequired) + { + updateInputOutputState(inputAddress, outputOffset, input, output); + + checkState(inputRequired >= 0, "inputRequired is negative"); + this.inputRequired = inputRequired; + + this.requestedOutputSize = 0; + } + + private void updateInputOutputState(long inputAddress, int outputOffset, long input, int output) + { + inputConsumed = (int) (input - inputAddress); + checkState(inputConsumed >= 0, "inputConsumed is negative"); + outputBufferUsed = output - outputOffset; + checkState(outputBufferUsed >= 0, "outputBufferUsed is negative"); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java new file mode 100644 index 00000000..53d5f798 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdInputStream.java @@ -0,0 +1,152 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.Util.checkPositionIndexes; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.max; +import static java.util.Objects.requireNonNull; + +public class ZstdInputStream + extends InputStream +{ + private static final int MIN_BUFFER_SIZE = 4096; + + private final InputStream inputStream; + private final ZstdIncrementalFrameDecompressor decompressor = new ZstdIncrementalFrameDecompressor(); + + private byte[] inputBuffer = new byte[decompressor.getInputRequired()]; + private MemorySegment inputBufferSegment = MemorySegment.ofArray(inputBuffer); + private int inputBufferOffset; + private int inputBufferLimit; + + private byte[] singleByteOutputBuffer; + + private boolean closed; + + public ZstdInputStream(InputStream inputStream) + { + this.inputStream = requireNonNull(inputStream, "inputStream is null"); + } + + @Override + public int read() + throws IOException + { + if (singleByteOutputBuffer == null) { + singleByteOutputBuffer = new byte[1]; + } + int readSize = read(singleByteOutputBuffer, 0, 1); + checkState(readSize != 0, "A zero read size should never be returned"); + if (readSize != 1) { + return -1; + } + return singleByteOutputBuffer[0] & 0xFF; + } + + @Override + public int read(final byte[] outputBuffer, final int outputOffset, final int outputLength) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + if (outputBuffer == null) { + throw new NullPointerException(); + } + checkPositionIndexes(outputOffset, outputOffset + outputLength, outputBuffer.length); + if (outputLength == 0) { + return 0; + } + + final int outputLimit = outputOffset + outputLength; + int outputUsed = 0; + while (outputUsed < outputLength) { + boolean enoughInput = fillInputBufferIfNecessary(decompressor.getInputRequired()); + if (!enoughInput) { + if (decompressor.isAtStoppingPoint()) { + return outputUsed > 0 ? outputUsed : -1; + } + throw new IOException("Not enough input bytes"); + } + + decompressor.partialDecompress( + inputBufferSegment, + inputBufferOffset, + inputBufferLimit, + outputBuffer, + outputOffset + outputUsed, + outputLimit); + + inputBufferOffset += decompressor.getInputConsumed(); + outputUsed += decompressor.getOutputBufferUsed(); + } + return outputUsed; + } + + private boolean fillInputBufferIfNecessary(int requiredSize) + throws IOException + { + if (inputBufferLimit - inputBufferOffset >= requiredSize) { + return true; + } + + if (inputBufferOffset > 0) { + int copySize = inputBufferLimit - inputBufferOffset; + System.arraycopy(inputBuffer, inputBufferOffset, inputBuffer, 0, copySize); + inputBufferOffset = 0; + inputBufferLimit = copySize; + } + + if (inputBuffer.length < requiredSize) { + inputBuffer = Arrays.copyOf(inputBuffer, max(requiredSize, MIN_BUFFER_SIZE)); + inputBufferSegment = MemorySegment.ofArray(inputBuffer); + } + + while (inputBufferLimit < inputBuffer.length) { + int readSize = inputStream.read(inputBuffer, inputBufferLimit, inputBuffer.length - inputBufferLimit); + if (readSize < 0) { + break; + } + inputBufferLimit += readSize; + } + return inputBufferLimit >= requiredSize; + } + + @Override + public int available() + throws IOException + { + if (closed) { + return 0; + } + return decompressor.getRequestedOutputSize(); + } + + @Override + public void close() + throws IOException + { + if (!closed) { + closed = true; + inputStream.close(); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java new file mode 100644 index 00000000..8233daa2 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaCompressor.java @@ -0,0 +1,90 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.Constants.MAX_BLOCK_SIZE; +import static java.lang.Math.addExact; +import static java.lang.String.format; +import static java.lang.ref.Reference.reachabilityFence; +import static java.util.Objects.requireNonNull; + +public class ZstdJavaCompressor + implements ZstdCompressor +{ + @Override + public int maxCompressedLength(int uncompressedSize) + { + int result = uncompressedSize + (uncompressedSize >>> 8); + + if (uncompressedSize < MAX_BLOCK_SIZE) { + result += (MAX_BLOCK_SIZE - uncompressedSize) >>> 11; + } + + return result; + } + + @Override + public int compress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + verifyRange(input, inputOffset, inputLength); + verifyRange(output, outputOffset, maxOutputLength); + + MemorySegment inputSegment = MemorySegment.ofArray(input); + MemorySegment outputSegment = MemorySegment.ofArray(output); + + return ZstdFrameCompressor.compress( + inputSegment, + inputOffset, + inputOffset + inputLength, + outputSegment, + outputOffset, + outputOffset + maxOutputLength, + CompressionParameters.DEFAULT_COMPRESSION_LEVEL); + } + + @Override + public int compress(MemorySegment input, MemorySegment output) + { + try { + long inputAddress = 0; + long inputLimit = addExact(inputAddress, input.byteSize()); + + long outputAddress = 0; + long outputLimit = addExact(outputAddress, output.byteSize()); + + return ZstdFrameCompressor.compress( + input, + inputAddress, + inputLimit, + output, + outputAddress, + outputLimit, + CompressionParameters.DEFAULT_COMPRESSION_LEVEL); + } + finally { + reachabilityFence(input); + reachabilityFence(output); + } + } + + private static void verifyRange(byte[] data, int offset, int length) + { + requireNonNull(data, "data is null"); + if (offset < 0 || length < 0 || offset + length > data.length) { + throw new IllegalArgumentException(format("Invalid offset or length (%s, %s) in array of length %s", offset, length, data.length)); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java new file mode 100644 index 00000000..a744901a --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdJavaDecompressor.java @@ -0,0 +1,87 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.MalformedInputException; + +import java.lang.foreign.MemorySegment; + +import static java.lang.Math.addExact; +import static java.lang.String.format; +import static java.lang.ref.Reference.reachabilityFence; +import static java.util.Objects.requireNonNull; + +public class ZstdJavaDecompressor + implements ZstdDecompressor +{ + private final ZstdFrameDecompressor decompressor = new ZstdFrameDecompressor(); + + @Override + public int decompress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + throws MalformedInputException + { + verifyRange(input, inputOffset, inputLength); + verifyRange(output, outputOffset, maxOutputLength); + + MemorySegment inputSegment = MemorySegment.ofArray(input); + MemorySegment outputSegment = MemorySegment.ofArray(output); + + return decompressor.decompress( + inputSegment, + inputOffset, + inputOffset + inputLength, + outputSegment, + outputOffset, + outputOffset + maxOutputLength); + } + + @Override + public int decompress(MemorySegment input, MemorySegment output) + throws MalformedInputException + { + try { + long inputAddress = 0; + long inputLimit = addExact(inputAddress, input.byteSize()); + + long outputAddress = 0; + long outputLimit = addExact(outputAddress, output.byteSize()); + + return decompressor.decompress( + input, + inputAddress, + inputLimit, + output, + outputAddress, + outputLimit); + } + finally { + reachabilityFence(input); + reachabilityFence(output); + } + } + + @Override + public long getDecompressedSize(byte[] input, int offset, int length) + { + return ZstdFrameDecompressor.getDecompressedSize(MemorySegment.ofArray(input), offset, offset + length); + } + + private static void verifyRange(byte[] data, int offset, int length) + { + requireNonNull(data, "data is null"); + if (offset < 0 || length < 0 || offset + length > data.length) { + throw new IllegalArgumentException(format("Invalid offset or length (%s, %s) in array of length %s", offset, length, data.length)); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java new file mode 100644 index 00000000..cc64a221 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNative.java @@ -0,0 +1,190 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import io.airlift.compress.v3.internal.NativeLoader; +import io.airlift.compress.v3.internal.NativeSignature; + +import java.lang.foreign.MemorySegment; +import java.lang.invoke.MethodHandle; +import java.util.Optional; + +import static java.lang.invoke.MethodHandles.lookup; + +final class ZstdNative +{ + private record MethodHandles( + @NativeSignature(name = "ZSTD_compressBound", returnType = long.class, argumentTypes = long.class) + MethodHandle maxCompressedLength, + @NativeSignature(name = "ZSTD_compress", returnType = long.class, argumentTypes = {MemorySegment.class, long.class, MemorySegment.class, long.class, int.class}) + MethodHandle compress, + @NativeSignature(name = "ZSTD_decompress", returnType = long.class, argumentTypes = {MemorySegment.class, long.class, MemorySegment.class, long.class}) + MethodHandle decompress, + @NativeSignature(name = "ZSTD_getFrameContentSize", returnType = long.class, argumentTypes = {MemorySegment.class, long.class}) + MethodHandle uncompressedLength, + @NativeSignature(name = "ZSTD_isError", returnType = int.class, argumentTypes = long.class) + MethodHandle isError, + @NativeSignature(name = "ZSTD_getErrorName", returnType = MemorySegment.class, argumentTypes = long.class) + MethodHandle getErrorName, + @NativeSignature(name = "ZSTD_defaultCLevel", returnType = int.class, argumentTypes = {}) + MethodHandle defaultCLevel) {} + + private ZstdNative() {} + + private static final Optional LINKAGE_ERROR; + private static final MethodHandle MAX_COMPRESSED_LENGTH_METHOD; + private static final MethodHandle COMPRESS_METHOD; + private static final MethodHandle DECOMPRESS_METHOD; + private static final MethodHandle UNCOMPRESSED_LENGTH_METHOD; + private static final MethodHandle IS_ERROR_METHOD; + private static final MethodHandle GET_ERROR_NAME_METHOD; + + public static final int DEFAULT_COMPRESSION_LEVEL; + + private static final long CONTENT_SIZE_UNKNOWN = -1L; + + static { + NativeLoader.Symbols symbols = NativeLoader.loadSymbols("zstd", MethodHandles.class, lookup()); + LINKAGE_ERROR = symbols.linkageError(); + MethodHandles methodHandles = symbols.symbols(); + MAX_COMPRESSED_LENGTH_METHOD = methodHandles.maxCompressedLength(); + COMPRESS_METHOD = methodHandles.compress(); + DECOMPRESS_METHOD = methodHandles.decompress(); + UNCOMPRESSED_LENGTH_METHOD = methodHandles.uncompressedLength(); + IS_ERROR_METHOD = methodHandles.isError(); + GET_ERROR_NAME_METHOD = methodHandles.getErrorName(); + if (LINKAGE_ERROR.isEmpty()) { + try { + DEFAULT_COMPRESSION_LEVEL = (int) methodHandles.defaultCLevel().invokeExact(); + } + catch (Throwable e) { + throw new ExceptionInInitializerError(e); + } + } + else { + DEFAULT_COMPRESSION_LEVEL = -1; + } + } + + public static boolean isEnabled() + { + return LINKAGE_ERROR.isEmpty(); + } + + public static void verifyEnabled() + { + if (LINKAGE_ERROR.isPresent()) { + throw new IllegalStateException("Zstd native library is not enabled", LINKAGE_ERROR.get()); + } + } + + public static long maxCompressedLength(long inputLength) + { + long result; + try { + result = (long) MAX_COMPRESSED_LENGTH_METHOD.invokeExact(inputLength); + } + catch (Throwable e) { + throw new AssertionError("should not reach here", e); + } + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during compression: " + getErrorName(result)); + } + return result; + } + + public static long compress(MemorySegment input, long inputLength, MemorySegment compressed, long compressedLength, int compressionLevel) + { + long result; + try { + result = (long) COMPRESS_METHOD.invokeExact(compressed, compressedLength, input, inputLength, compressionLevel); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during compression: " + getErrorName(result)); + } + return result; + } + + public static long decompress(MemorySegment compressed, long compressedLength, MemorySegment output, long outputLength) + { + long result; + try { + result = (long) DECOMPRESS_METHOD.invokeExact(output, outputLength, compressed, compressedLength); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (isError(result)) { + throw new IllegalArgumentException("Unknown error occurred during decompression: " + getErrorName(result)); + } + return result; + } + + public static long decompressedLength(MemorySegment compressed, long compressedLength) + { + long result; + try { + result = (long) UNCOMPRESSED_LENGTH_METHOD.invokeExact(compressed, compressedLength); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + + if (CONTENT_SIZE_UNKNOWN != result && result < 0) { + throw new IllegalArgumentException("Unknown error occurred during decompression: " + getErrorName(result)); + } + return result; + } + + private static boolean isError(long code) + { + try { + return (int) IS_ERROR_METHOD.invokeExact(code) != 0; + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + } + + private static String getErrorName(long code) + { + try { + MemorySegment name = (MemorySegment) GET_ERROR_NAME_METHOD.invokeExact(code); + return name.reinterpret(Long.MAX_VALUE).getString(0); + } + catch (Error e) { + throw e; + } + catch (Throwable e) { + throw new Error("Unexpected exception", e); + } + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java new file mode 100644 index 00000000..56083280 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeCompressor.java @@ -0,0 +1,61 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static io.airlift.compress.v3.zstdFFM.ZstdNative.DEFAULT_COMPRESSION_LEVEL; +import static java.lang.Math.toIntExact; + +public class ZstdNativeCompressor + implements ZstdCompressor +{ + private final int compressionLevel; + + public ZstdNativeCompressor() + { + this(DEFAULT_COMPRESSION_LEVEL); + } + + public ZstdNativeCompressor(int compressionLevel) + { + ZstdNative.verifyEnabled(); + this.compressionLevel = compressionLevel; + } + + public static boolean isEnabled() + { + return ZstdNative.isEnabled(); + } + + @Override + public int maxCompressedLength(int uncompressedSize) + { + return toIntExact(ZstdNative.maxCompressedLength(uncompressedSize)); + } + + @Override + public int compress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(inputOffset, inputLength); + MemorySegment outputSegment = MemorySegment.ofArray(output).asSlice(outputOffset, maxOutputLength); + return toIntExact(ZstdNative.compress(inputSegment, inputLength, outputSegment, maxOutputLength, compressionLevel)); + } + + @Override + public int compress(MemorySegment input, MemorySegment output) + { + return toIntExact(ZstdNative.compress(input, input.byteSize(), output, output.byteSize(), compressionLevel)); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java new file mode 100644 index 00000000..b28d8692 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdNativeDecompressor.java @@ -0,0 +1,53 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.lang.foreign.MemorySegment; + +import static java.lang.Math.toIntExact; + +public class ZstdNativeDecompressor + implements ZstdDecompressor +{ + public ZstdNativeDecompressor() + { + ZstdNative.verifyEnabled(); + } + + public static boolean isEnabled() + { + return ZstdNative.isEnabled(); + } + + @Override + public int decompress(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, int maxOutputLength) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(inputOffset, inputLength); + MemorySegment outputSegment = MemorySegment.ofArray(output).asSlice(outputOffset, maxOutputLength); + return toIntExact(ZstdNative.decompress(inputSegment, inputLength, outputSegment, maxOutputLength)); + } + + @Override + public int decompress(MemorySegment input, MemorySegment output) + { + return toIntExact(ZstdNative.decompress(input, input.byteSize(), output, output.byteSize())); + } + + @Override + public long getDecompressedSize(byte[] input, int offset, int length) + { + MemorySegment inputSegment = MemorySegment.ofArray(input).asSlice(offset, length); + return ZstdNative.decompressedLength(inputSegment, length); + } +} diff --git a/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java new file mode 100644 index 00000000..ca3e62f8 --- /dev/null +++ b/src/main/java/io/airlift/compress/v3/zstdFFM/ZstdOutputStream.java @@ -0,0 +1,211 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.airlift.compress.v3.zstdFFM; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.foreign.MemorySegment; +import java.util.Arrays; + +import static io.airlift.compress.v3.zstdFFM.CompressionParameters.DEFAULT_COMPRESSION_LEVEL; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_BLOCK_HEADER; +import static io.airlift.compress.v3.zstdFFM.Constants.SIZE_OF_LONG; +import static io.airlift.compress.v3.zstdFFM.Util.checkState; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.util.Objects.requireNonNull; + +public class ZstdOutputStream + extends OutputStream +{ + private final OutputStream outputStream; + private final CompressionContext context; + private final int maxBufferSize; + + private XxHash64 partialHash; + + private byte[] uncompressed = new byte[0]; + private MemorySegment uncompressedSegment = MemorySegment.ofArray(uncompressed); + private final byte[] compressed; + private final MemorySegment compressedSegment; + + // start of unprocessed data in uncompressed buffer + private int uncompressedOffset; + // end of unprocessed data in uncompressed buffer + private int uncompressedPosition; + + private boolean closed; + + public ZstdOutputStream(OutputStream outputStream) + throws IOException + { + this.outputStream = requireNonNull(outputStream, "outputStream is null"); + this.context = new CompressionContext(CompressionParameters.compute(DEFAULT_COMPRESSION_LEVEL, -1), 0, Integer.MAX_VALUE); + this.maxBufferSize = context.parameters.getWindowSize() * 4; + + int bufferSize = context.parameters.getBlockSize() + SIZE_OF_BLOCK_HEADER; + this.compressed = new byte[bufferSize + (bufferSize >>> 8) + SIZE_OF_LONG]; + this.compressedSegment = MemorySegment.ofArray(compressed); + } + + @Override + public void write(int b) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + growBufferIfNecessary(1); + + uncompressed[uncompressedPosition++] = (byte) b; + + compressIfNecessary(); + } + + @Override + public void write(byte[] buffer) + throws IOException + { + write(buffer, 0, buffer.length); + } + + @Override + public void write(byte[] buffer, int offset, int length) + throws IOException + { + if (closed) { + throw new IOException("Stream is closed"); + } + + growBufferIfNecessary(length); + + while (length > 0) { + int writeSize = min(length, uncompressed.length - uncompressedPosition); + System.arraycopy(buffer, offset, uncompressed, uncompressedPosition, writeSize); + + uncompressedPosition += writeSize; + length -= writeSize; + offset += writeSize; + + compressIfNecessary(); + } + } + + private void growBufferIfNecessary(int length) + { + if (uncompressedPosition + length <= uncompressed.length || uncompressed.length >= maxBufferSize) { + return; + } + + int newSize = (uncompressed.length + length) * 2; + newSize = min(newSize, maxBufferSize); + newSize = max(newSize, context.parameters.getBlockSize()); + uncompressed = Arrays.copyOf(uncompressed, newSize); + uncompressedSegment = MemorySegment.ofArray(uncompressed); + } + + private void compressIfNecessary() + throws IOException + { + if (uncompressed.length >= maxBufferSize && + uncompressedPosition == uncompressed.length && + uncompressed.length - context.parameters.getWindowSize() > context.parameters.getBlockSize()) { + writeChunk(false); + } + } + + // visible for Hadoop stream + void finishWithoutClosingSource() + throws IOException + { + if (!closed) { + writeChunk(true); + closed = true; + } + } + + @Override + public void close() + throws IOException + { + if (!closed) { + writeChunk(true); + + closed = true; + outputStream.close(); + } + } + + private void writeChunk(boolean lastChunk) + throws IOException + { + int chunkSize; + if (lastChunk) { + chunkSize = uncompressedPosition - uncompressedOffset; + } + else { + int blockSize = context.parameters.getBlockSize(); + chunkSize = uncompressedPosition - uncompressedOffset - context.parameters.getWindowSize() - blockSize; + checkState(chunkSize > blockSize, "Must write at least one full block"); + chunkSize = (chunkSize / blockSize) * blockSize; + } + + if (partialHash == null) { + partialHash = new XxHash64(); + + int inputSize = lastChunk ? chunkSize : -1; + + long outputAddress = 0; + outputAddress += ZstdFrameCompressor.writeMagic(compressedSegment, outputAddress, outputAddress + 4); + outputAddress += ZstdFrameCompressor.writeFrameHeader(compressedSegment, outputAddress, outputAddress + 14, inputSize, context.parameters.getWindowSize()); + outputStream.write(compressed, 0, (int) outputAddress); + } + + partialHash.update(uncompressed, uncompressedOffset, chunkSize); + + do { + int blockSize = min(chunkSize, context.parameters.getBlockSize()); + int compressedSize = ZstdFrameCompressor.writeCompressedBlock( + uncompressedSegment, + uncompressedOffset, + blockSize, + compressedSegment, + 0, + compressed.length, + context, + lastChunk && blockSize == chunkSize); + outputStream.write(compressed, 0, compressedSize); + uncompressedOffset += blockSize; + chunkSize -= blockSize; + } + while (chunkSize > 0); + + if (lastChunk) { + int hash = (int) partialHash.hash(); + outputStream.write(hash); + outputStream.write(hash >> 8); + outputStream.write(hash >> 16); + outputStream.write(hash >> 24); + } + else { + int slideWindowSize = uncompressedOffset - context.parameters.getWindowSize(); + context.slideWindow(slideWindowSize); + + System.arraycopy(uncompressed, slideWindowSize, uncompressed, 0, context.parameters.getWindowSize() + (uncompressedPosition - uncompressedOffset)); + uncompressedOffset -= slideWindowSize; + uncompressedPosition -= slideWindowSize; + } + } +} From 7361201e6a8c5d5e6ef99fadee4c573382419d84 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 01:40:12 -0400 Subject: [PATCH 04/17] Created threaded parallel writer --- .../beakgraph/cmdline/BeakGraphCLI.java | 14 + .../ebremer/beakgraph/cmdline/Parameters.java | 13 + .../PositionalDictionaryWriterBuilder.java | 14 +- .../writers/parallel/ParallelBGIndex.java | 360 ++++++++++++++++++ .../writers/parallel/ParallelHDF5Writer.java | 163 ++++++++ .../ParallelPositionalDictionaryWriter.java | 273 +++++++++++++ ...llelPositionalDictionaryWriterBuilder.java | 68 ++++ .../hdf5/writers/parallel/package-info.java | 39 ++ .../cmdline/BeakGraphCLIConversionTest.java | 40 ++ .../parallel/ParallelWriterParityTest.java | 360 ++++++++++++++++++ 10 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriter.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index 118c4944..c0251288 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -5,6 +5,7 @@ import com.ebremer.beakgraph.Params; import com.ebremer.beakgraph.core.fuseki.SPARQLEndPoint; import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer; import com.ebremer.beakgraph.huge.HugeHDF5Writer; import java.io.FileNotFoundException; import java.io.IOException; @@ -226,6 +227,19 @@ public Model call() { builder.setWorkDirectory(params.workdir.toPath()); } builder.build().write(); + } else if (params.parallel) { + // Multi-threaded in-memory build: same output format, but each + // file's dictionaries, id lists, and indexes are built + // concurrently on a pool of -cores threads (default 4). With + // -threads N, N conversions run at once, each with its own pool. + ParallelHDF5Writer.Builder() + .setSource(src.toFile()) + .setDestination(dest.toFile()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores) + .build() + .write(); } else { HDF5Writer.Builder() .setSource(src.toFile()) diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index 71ddf798..69079140 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -2,6 +2,7 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.converters.BooleanConverter; +import com.beust.jcommander.validators.PositiveInteger; import java.io.File; /** @@ -42,6 +43,18 @@ public class Parameters { + "destination file's directory)", required = false) public File workdir = null; + @Parameter(names = {"-parallel"}, converter = BooleanConverter.class, + description = "Use the multi-threaded in-memory writer " + + "(com.ebremer.beakgraph.hdf5.writers.parallel): builds each file's " + + "dictionaries, columnar id lists, and GSPO/GPOS indexes concurrently " + + "on -cores threads. Ignored when -huge is set") + public boolean parallel = false; + + @Parameter(names = "-cores", validateWith = PositiveInteger.class, + description = "# of threads each -parallel conversion may use (with -threads N, " + + "N conversions run at once, each capped at -cores)") + public int cores = 4; + @Parameter(names = {"-version","-v"}, converter = BooleanConverter.class) public boolean version = false; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java index 48b1986a..75cc7587 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java @@ -594,6 +594,19 @@ private void ProcessQuad(Quad quad) { } public PositionalDictionaryWriter build() throws IOException { + parse(); + return new PositionalDictionaryWriter(this); + } + + /** + * Runs the full ingest pipeline - parse, bnode alignment, numeric + * canonicalization, spatial/feature augmentation, VoID statistics - leaving + * the collected quads, node sets, and stats in this builder. Shared verbatim + * by {@link #build()} and the parallel subclass + * (com.ebremer.beakgraph.hdf5.writers.parallel), which differ only in which + * dictionary writer they construct from the collected state. + */ + protected final void parse() throws IOException { final AtomicLong quadcount = new AtomicLong(); logger.trace("Creating dictionary..."); try (InputStream xis = src.toString().endsWith(".gz") @@ -696,7 +709,6 @@ public PositionalDictionaryWriter build() throws IOException { this.quads = quadslist.toArray(Quad[]::new); quadslist.clear(); logger.info("Dictionary created. Total quads: {}", this.numQuads); - return new PositionalDictionaryWriter(this); } private boolean isGeoLiteral(Quad quad) { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java new file mode 100644 index 00000000..fcaed5ce --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelBGIndex.java @@ -0,0 +1,360 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import static com.ebremer.beakgraph.Params.BLOCKSIZE; +import static com.ebremer.beakgraph.Params.SUPERBLOCKSIZE; +import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import io.jhdf.api.WritableGroup; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.stream.IntStream; +import org.apache.jena.sparql.core.Quad; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Multi-threaded twin of {@link com.ebremer.beakgraph.hdf5.writers.BGIndex}. + * + *

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

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

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

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

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

Only the build is parallel; the jHDF write of the finished buffers is the + * sequential writer's, and files are read by the same unmodified readers + * ({@code HDF5Reader}). Given the same parsed quads, the output is + * byte-identical to the sequential writer's; across separate writes only the + * VoID metadata's freshly minted blank-node labels differ, exactly as between + * two sequential runs (see ParallelWriterParityTest). + */ +package com.ebremer.beakgraph.hdf5.writers.parallel; diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java index bf3eb119..ed62fd03 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java @@ -77,6 +77,46 @@ void hugeFlagConvertsThroughDiskBasedWriter() throws Exception { } } + @Test + void parallelFlagConvertsThroughParallelWriter() throws Exception { + Path src = Files.createDirectories(dir.resolve("srcpar")); + Files.write(src.resolve("data.ttl"), + " .\n".getBytes(StandardCharsets.UTF_8)); + + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outpar").toFile(); + p.parallel = true; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.traverse(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), + "the -parallel conversion must succeed"); + File h5 = dir.resolve("outpar").resolve("data.h5").toFile(); + assertTrue(h5.exists() && h5.length() > 0, "the -parallel writer must produce the .h5 file"); + // The produced store must be readable by the standard reader stack. + try (com.ebremer.beakgraph.core.BeakGraph bg = new com.ebremer.beakgraph.core.BeakGraph( + new com.ebremer.beakgraph.hdf5.readers.HDF5Reader(h5))) { + assertTrue(bg.find(org.apache.jena.graph.NodeFactory.createURI("http://ex.org/a"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/p"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/b")).hasNext(), + "the converted triple must be queryable"); + } + } + + @Test + void parallelAndCoresOptionsParse() { + // The exact spellings the writer is documented with: -parallel and -cores. + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x", "-parallel", "-cores", "6"); + assertTrue(p.parallel, "-parallel must set the flag"); + assertEquals(6, p.cores, "-cores must override the default"); + assertEquals(4, new Parameters().cores, "-cores must default to 4"); + } + @Test void missingDestinationFailsEveryFileLoudlyInsteadOfSilently() throws Exception { // main() refuses to start without -dest; if a processor is ever reached diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java new file mode 100644 index 00000000..28c751b4 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelWriterParityTest.java @@ -0,0 +1,360 @@ +package com.ebremer.beakgraph.hdf5.writers.parallel; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.HdfFile; +import io.jhdf.api.Attribute; +import io.jhdf.api.Dataset; +import io.jhdf.api.Group; +import io.jhdf.api.Node; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.sparql.core.Quad; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity: the parallel writer must produce a store that reads back + * (through the UNMODIFIED jHDF-based readers) semantically identical to the + * sequential writer's - same graphs, isomorphic per-graph content, same query + * answers through both indexes, and structurally identical HDF5 metadata + * (same dataset tree, same sizes, same numEntries / width / FCD attributes + * everywhere). Raw bytes are NOT compared: the VoID generator mints fresh + * blank-node labels on every write, so bnode dictionary ranks legitimately + * differ between any two writes - including two sequential ones (same caveat + * as HugeWriterParityTest). + * + *

{@link #idTupleOrderMatchesNodeComparatorOrder()} separately pins the + * parallel index's core claim - sorting quads by dictionary-id tuple is + * exactly the sequential writer's NodeComparator quad order - against real + * parsed data including randomly-labeled VoID bnodes. + */ +class ParallelWriterParityTest { + + @TempDir + static Path dir; + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + /** Writes src with both writers; returns the directory holding {name}.seq.h5 / {name}.par.h5. */ + private static Path writeBoth(String name, String content, boolean spatial, boolean features) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File par = dir.resolve(name + ".par.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq) + .setSpatial(spatial).setFeatures(features).build().write(); + ParallelHDF5Writer.Builder().setSource(src).setDestination(par) + .setSpatial(spatial).setFeatures(features) + // deliberately odd core count: shakes out anything that silently + // assumes the default or an even split + .setCores(3) + .build().write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + /** Semantic equality of the two stores, graph by graph. */ + private static void assertStoresEquivalent(Path seqH5, Path parH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(parH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", par=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; // skip any non-URI form + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", par=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + /** + * Structural parity of the HDF5 trees: identical group/dataset names, + * identical dataset sizes, and identical attribute values everywhere. + * Counts and bit widths are bnode-order independent, so this must hold + * exactly even though raw bytes may differ (see the class comment). + */ + private static void assertSameStructure(Path seqH5, Path parH5) { + try (HdfFile seq = new HdfFile(seqH5); HdfFile par = new HdfFile(parH5)) { + compareGroups("/", (Group) seq.getChild(".BG"), (Group) par.getChild(".BG")); + } + } + + private static void compareGroups(String path, Group a, Group b) { + assertEquals(a != null, b != null, "group presence at " + path); + if (a == null) return; + compareAttributes(path, a, b); + Map ca = a.getChildren(); + Map cb = b.getChildren(); + assertEquals(new TreeSet<>(ca.keySet()), new TreeSet<>(cb.keySet()), "children of " + path); + for (String name : ca.keySet()) { + Node na = ca.get(name); + Node nb = cb.get(name); + assertEquals(na instanceof Group, nb instanceof Group, "node kind of " + path + name); + if (na instanceof Group ga) { + compareGroups(path + name + "/", ga, (Group) nb); + } else { + compareAttributes(path + name, na, nb); + assertEquals(((Dataset) na).getSize(), ((Dataset) nb).getSize(), + "dataset size of " + path + name); + } + } + } + + private static void compareAttributes(String path, Node a, Node b) { + Map attrsA = new HashMap<>(); + Map attrsB = new HashMap<>(); + for (Map.Entry e : a.getAttributes().entrySet()) { + attrsA.put(e.getKey(), e.getValue().getData()); + } + for (Map.Entry e : b.getAttributes().entrySet()) { + attrsB.put(e.getKey(), e.getValue().getData()); + } + assertEquals(attrsA, attrsB, "attributes of " + path); + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void mixedTypesAndNamedGraphs() throws Exception { + String longStr = "y".repeat(150); + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:name "Alicia"@fr-CA . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:count "042"^^xsd:int . + ex:s0 ex:count2 "-7"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:neg "-9007199254740993"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:longstr "%s" . + ex:s0 ex:uni "h\\u00e9llo \\u00fcrld" . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + ex:s1 ex:num "10"^^xsd:int . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """.formatted(longStr); + writeBoth("mixed.trig", trig, false, false); + Path seq = dir.resolve("mixed.trig.seq.h5"); + Path par = dir.resolve("mixed.trig.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?s ?o WHERE { GRAPH { ?s ?o } }", + "SELECT ?o WHERE { ?o }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + assertSameStructure(seq, par); + } + + @Test + void spatialAndFeaturesParity() throws Exception { + String trig = """ + @prefix ex: . + @prefix geo: . + + ex:geo1 geo:asWKT "POLYGON((0 0, 100 0, 100 100, 0 100, 0 0))"^^geo:wktLiteral . + ex:geo2 geo:asWKT "MULTIPOLYGON(((200 200, 300 200, 300 300, 200 200)),((600 600, 700 600, 700 700, 600 600)))"^^geo:wktLiteral . + ex:geo3 geo:asWKT "POINT(5000 6000)"^^geo:wktLiteral . + ex:geo4 geo:asWKT " POLYGON((10 10, 60 10, 60 60, 10 60, 10 10))"^^geo:wktLiteral . + ex:geo5 geo:asWKT "POLYGON((0 0, 1 0))"^^geo:wktLiteral . + ex:geo1 ex:label "region one" . + """; + writeBoth("spatial.trig", trig, true, true); + Path seq = dir.resolve("spatial.trig.seq.h5"); + Path par = dir.resolve("spatial.trig.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } }", + "SELECT ?g WHERE { GRAPH ?g { ?p ?o } }"); + assertSameStructure(seq, par); + } + + @Test + void stressManyQuadsMatchesSequentialWriter() throws Exception { + StringBuilder nq = new StringBuilder(1 << 22); + for (int i = 0; i < 30_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 1000) + "\""; + case 2 -> "\"" + (i % 1000 - 500) + "\"^^"; + default -> "\"" + ((i % 100) / 8.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + } + writeBoth("stress.nq", nq.toString(), false, false); + Path seq = dir.resolve("stress.nq.seq.h5"); + Path par = dir.resolve("stress.nq.par.h5"); + assertStoresEquivalent(seq, par, + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + assertSameStructure(seq, par); + } + + @Test + void singleCoreStillWorks() throws Exception { + String ttl = " .\n" + + " \"x\" .\n"; + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), ttl.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve("one.ttl.seq.h5").toFile(); + File par = dir.resolve("one.ttl.par.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + ParallelHDF5Writer.Builder().setSource(src).setDestination(par).setCores(1).build().write(); + assertStoresEquivalent(seq.toPath(), par.toPath(), + "SELECT ?o WHERE { ?p ?o }"); + assertSameStructure(seq.toPath(), par.toPath()); + } + + /** + * The parallel index sorts quads by their dictionary-id tuple instead of + * comparing nodes. This pins the equivalence directly: for both orderings, + * mapping the NodeComparator-sorted quad sequence to id tuples yields + * exactly the id-sorted tuple sequence. Exercised against real parsed + * state - randomly-labeled VoID bnodes, aligned data bnodes, value-equal + * literals of different terms, langStrings, a bnode-labeled named graph - + * so every macro-ordering rule the claim rests on is present. + */ + @Test + void idTupleOrderMatchesNodeComparatorOrder() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s ex:v "1"^^xsd:int . + ex:s ex:v "1"^^xsd:long . + ex:s ex:v "1.0"^^xsd:double . + ex:s ex:v "1"^^xsd:float . + ex:s ex:v "01"^^xsd:integer . + ex:s ex:w "a" . + ex:s ex:w "a"@en . + ex:s ex:w "a"@de . + ex:s ex:when "2020-01-02T00:00:00"^^xsd:dateTime . + ex:s ex:when "2020-01-02T08:00:00+14:00"^^xsd:dateTime . + _:x ex:p _:y . + _:y ex:p ex:s . + <> ex:self . + _:g { _:x ex:q "in bnode graph" . } + ex:g { ex:s ex:q _:y . } + """; + File src = dir.resolve("order.trig").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + + try (ParallelPositionalDictionaryWriter w = new ParallelPositionalDictionaryWriterBuilder() + .setSource(src) + .setDestination(dir.resolve("order.unused.h5").toFile()) + .setName(Params.DICTIONARY) + .buildParallel()) { + Quad[] quads = w.getQuads(); + for (Index type : Index.values()) { + Quad[] byNodes = quads.clone(); + Arrays.sort(byNodes, type.getComparator()); + long[][] mapped = Arrays.stream(byNodes) + .map(q -> tuple(w, type, q)) + .toArray(long[][]::new); + long[][] byIds = mapped.clone(); + Arrays.sort(byIds, Arrays::compare); + assertTrue(Arrays.deepEquals(mapped, byIds), + type + ": sorting by dictionary-id tuple must equal the NodeComparator quad order"); + } + } + } + + private static long[] tuple(ParallelPositionalDictionaryWriter w, Index type, Quad q) { + long[] t = new long[4]; + String order = type.name(); // e.g. "GSPO" + for (int i = 0; i < 4; i++) { + t[i] = switch (order.charAt(i)) { + case 'G' -> w.locateGraph(q.getGraph()); + case 'S' -> w.locateSubject(q.getSubject()); + case 'P' -> w.locatePredicate(q.getPredicate()); + case 'O' -> w.locateObject(q.getObject()); + default -> throw new IllegalStateException(); + }; + } + return t; + } +} From 39bc37b654bfbefa7438119546364140bc3db967 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 02:20:17 -0400 Subject: [PATCH 05/17] add merge src function --- .../beakgraph/cmdline/BeakGraphCLI.java | 104 +++++++- .../ebremer/beakgraph/cmdline/Parameters.java | 8 + .../beakgraph/core/AbstractGraphBuilder.java | 16 +- .../beakgraph/hdf5/writers/HDF5Writer.java | 1 + .../PositionalDictionaryWriterBuilder.java | 128 ++++++---- .../writers/parallel/ParallelHDF5Writer.java | 1 + ...llelPositionalDictionaryWriterBuilder.java | 7 + .../beakgraph/huge/HugeBuildPipeline.java | 96 +++++--- .../beakgraph/huge/HugeHDF5Writer.java | 13 +- .../ebremer/beakgraph/utils/RdfSources.java | 104 ++++++++ .../cmdline/MergeAndFormatsTest.java | 227 ++++++++++++++++++ 11 files changed, 613 insertions(+), 92 deletions(-) create mode 100644 src/main/java/com/ebremer/beakgraph/utils/RdfSources.java create mode 100644 src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index c0251288..222c5119 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -7,11 +7,15 @@ import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; import com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer; import com.ebremer.beakgraph.huge.HugeHDF5Writer; +import com.ebremer.beakgraph.utils.RdfSources; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -48,7 +52,9 @@ public BeakGraphCLI(Parameters params) { this.fc = new FileCounter(); String os = System.getProperty("os.name").toLowerCase(); ProgressBarStyle style = os.contains("win") ? ProgressBarStyle.ASCII : ProgressBarStyle.COLORFUL_UNICODE_BLOCK; - if (params.status) { + // -merge is one big conversion, not a stream of per-file tasks; the + // per-file progress bar would only ever render empty. + if (params.status && !params.merge) { progressBar = new ProgressBarBuilder() .setTaskName("Processing RDF Source Files...") .setInitialMax(0) @@ -100,7 +106,11 @@ public static void main(String[] args) throws FileNotFoundException, IOException } JenaSystem.init(); BeakGraphCLI bg = new BeakGraphCLI(params); - bg.traverse(); + if (params.merge) { + bg.merge(); + } else { + bg.traverse(); + } if (bg.fc.getFailedConversionFileCount() > 0) { System.exit(2); } @@ -137,7 +147,7 @@ public void traverse() { fc.incrementZeroLengthFileCount(); return false; } - if (p.toFile().toString().toLowerCase().endsWith(".ttl.gz") || p.toFile().toString().toLowerCase().endsWith(".ttl")) { + if (RdfSources.isSupported(p.getFileName().toString())) { return true; } fc.incrementOtherFileCount(); @@ -173,6 +183,94 @@ public void traverse() { } } + /** + * -merge: parse every supported RDF source under -src into ONE BeakGraph + * HDF5 file at -dest (or {@code /merged.h5} when -dest is an existing + * directory). Sources are taken in sorted path order so repeated merges of + * the same tree are deterministic; blank nodes stay distinct per source + * document. The destination is rebuilt - the write is atomic, so a failed + * rebuild never destroys a previous good artifact. + */ + public void merge() { + final List inputs = new ArrayList<>(); + try (var walk = Files.walk(params.src.toPath())) { + walk.filter(p -> { + File f = p.toFile(); + if (f.isDirectory()) { + fc.incrementDirectoryCount(); + return false; + } + if (f.length() == 0) { + fc.incrementZeroLengthFileCount(); + return false; + } + if (RdfSources.isSupported(p.getFileName().toString())) { + return true; + } + fc.incrementOtherFileCount(); + return false; + }) + .sorted() + .forEach(p -> { + fc.incrementRDFFileCount(); + inputs.add(p.toFile()); + }); + } catch (IOException ex) { + logger.error("Failed to scan source tree {}", params.src, ex); + } + if (inputs.isEmpty()) { + System.err.println("No supported RDF sources found under " + params.src); + return; + } + File dest = params.dest; + if (dest.isDirectory()) { + dest = new File(dest, "merged.h5"); + } + if (dest.getParentFile() != null) { + dest.getParentFile().mkdirs(); + } + logger.info("Merging {} RDF sources into {}", inputs.size(), dest); + try { + if (params.huge) { + // Disk-based merge: all sources spill into one workspace/store; + // blank nodes stay distinct per document (see HugeBuildPipeline). + HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() + .setSources(inputs) + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + builder.build().write(); + } else if (params.parallel) { + ParallelHDF5Writer.Builder() + .setSources(inputs) + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores) + .build() + .write(); + } else { + HDF5Writer.Builder() + .setSources(inputs) + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features) + .build() + .write(); + } + } catch (Exception ex) { + fc.incrementFailedConversionFileCount(); + logger.error("Failed to merge {} sources into {}", inputs.size(), dest, ex); + } + if (params.status) { + System.out.println(fc); + } + } + public static Path mapToDestinationWithNewExtension(Path srcFile, Path srcDirectory, Path destDirectory, String newExt) { Path normalizedSrcFile = srcFile.normalize(); Path normalizedSrcDir = srcDirectory.normalize(); diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index 69079140..4db8563d 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -43,6 +43,14 @@ public class Parameters { + "destination file's directory)", required = false) public File workdir = null; + @Parameter(names = {"-merge"}, converter = BooleanConverter.class, + description = "Merge ALL RDF sources found under -src (typically a directory tree) " + + "into ONE BeakGraph HDF5 file at -dest instead of one .h5 per source " + + "(if -dest is an existing directory, writes /merged.h5; an " + + "existing destination file is rebuilt). Blank nodes stay distinct per " + + "source document. Works with the default, -parallel, and -huge writers") + public boolean merge = false; + @Parameter(names = {"-parallel"}, converter = BooleanConverter.class, description = "Use the multi-threaded in-memory writer " + "(com.ebremer.beakgraph.hdf5.writers.parallel): builds each file's " diff --git a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java index b010c85c..cf661786 100644 --- a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java @@ -1,13 +1,15 @@ package com.ebremer.beakgraph.core; import java.io.File; +import java.util.List; import org.apache.jena.query.Dataset; // T refers to the concrete class (e.g., HDF5Writer.Builder) public abstract class AbstractGraphBuilder> { - + protected File src; protected File dest; + protected List sources = List.of(); protected Dataset ds; protected boolean spatial; protected boolean features; @@ -24,6 +26,18 @@ public T setDestination(File file) { this.dest = file; return self(); } + + /** + * Merge mode: every given document is parsed into the ONE store being + * written (blank nodes stay distinct per document). When non-empty this + * takes precedence over {@link #setSource}. + */ + public T setSources(List files) { + this.sources = List.copyOf(files); + return self(); + } + + public List getSources() { return sources; } public T setSpatial(boolean flag) { this.spatial = flag; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java index 2e4380ce..1bf66496 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java @@ -42,6 +42,7 @@ public void write() throws IOException { PositionalDictionaryWriterBuilder db = new PositionalDictionaryWriterBuilder(); try (PositionalDictionaryWriter w = db .setSource(builder.getSource()) + .setSources(builder.getSources()) .setDestination(builder.getDestination()) .setName(Params.DICTIONARY) .setSpatial(builder.getSpatial()) diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java index 75cc7587..12deb3cb 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java @@ -4,14 +4,13 @@ import com.ebremer.beakgraph.core.fuseki.BGVoIDSD; import com.ebremer.beakgraph.core.lib.Stats; import com.ebremer.beakgraph.utils.ImageTools; +import com.ebremer.beakgraph.utils.RdfSources; import com.ebremer.halcyon.hilbert.HilbertSpace; import com.ebremer.halcyon.hilbert.PolygonScaler; import com.ebremer.halcyon.hilbert.WKTDatatype; import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -22,14 +21,11 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; -import java.util.zip.GZIPInputStream; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; import org.apache.jena.graph.Triple; import org.apache.jena.irix.IRIx; import org.apache.jena.rdf.model.Model; -import org.apache.jena.riot.Lang; -import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.lang.LabelToNode; import org.apache.jena.riot.system.AsyncParser; import org.apache.jena.riot.system.AsyncParserBuilder; @@ -56,6 +52,10 @@ public class PositionalDictionaryWriterBuilder { private static final Logger logger = LoggerFactory.getLogger(PositionalDictionaryWriterBuilder.class); private File src; private File dest; + /** When non-empty, ALL of these documents are parsed into ONE store (-merge); src is ignored. */ + private final List sources = new ArrayList<>(); + /** Replacement-label counter for AlignBnodes; never resets, so labels stay unique across merged sources. */ + private long bnodeCounter = 0; private final HashSet entities = new HashSet<>(); // URIs & BNodes from G, S, O private final HashSet predicates = new HashSet<>(); // URIs from P private final HashSet literals = new HashSet<>(); // Literals from O @@ -157,6 +157,17 @@ public PositionalDictionaryWriterBuilder setSource(File src) { this.src = src; return this; } + /** + * Merge mode: parse every given document into the one store being built. + * Blank nodes stay distinct per document; everything else (dictionaries, + * VoID statistics, indexes) is computed over the union. + */ + public PositionalDictionaryWriterBuilder setSources(List files) { + this.sources.clear(); + this.sources.addAll(files); + return this; + } + public PositionalDictionaryWriterBuilder setSpatial(boolean flag) { this.spatial = flag; return this; } @@ -357,7 +368,7 @@ private Quad AlignBnodes(Quad quad) { if (g.isBlank()||s.isBlank()||o.isBlank()) { if (g.isBlank()) { if (!bmap.containsKey(g)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(g, neo); g = neo; } else { @@ -366,7 +377,7 @@ private Quad AlignBnodes(Quad quad) { } if (s.isBlank()) { if (!bmap.containsKey(s)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(s, neo); s = neo; } else { @@ -375,7 +386,7 @@ private Quad AlignBnodes(Quad quad) { } if (o.isBlank()) { if (!bmap.containsKey(o)) { - Node neo = NodeFactory.createBlankNode(String.format("b%020d", bmap.size())); + Node neo = NodeFactory.createBlankNode(String.format("b%020d", bnodeCounter++)); bmap.put(o, neo); o = neo; } else { @@ -608,25 +619,62 @@ public PositionalDictionaryWriter build() throws IOException { */ protected final void parse() throws IOException { final AtomicLong quadcount = new AtomicLong(); - logger.trace("Creating dictionary..."); - try (InputStream xis = src.toString().endsWith(".gz") - ? new GZIPInputStream(new FileInputStream(src)) - : new FileInputStream(src)) { - // Detect the syntax from the file name (TriG, N-Quads, N-Triples, - // Turtle, ...; .gz handled) instead of hardcoding Turtle: this is a - // quad store, and named graphs can only arrive through a quad-capable - // syntax. - String fname = src.getName(); - if (fname.endsWith(".gz")) { - fname = fname.substring(0, fname.length() - 3); - } - Lang lang = RDFLanguages.filenameToLang(fname, Lang.TURTLE); + logger.trace("Creating dictionary..."); + if (sources.isEmpty() && src == null) { + throw new IllegalStateException("No source set: call setSource() or setSources()"); + } + final List inputs = sources.isEmpty() ? List.of(src) : List.copyOf(sources); + for (File input : inputs) { + parseSource(input, quadcount); + // Blank-node labels are document-scoped in RDF: two sources may both + // say _:b0 and mean different nodes (the parser keeps labels as + // given). Clearing the alignment map per document keeps them + // distinct, while the never-reset bnodeCounter keeps the replacement + // labels unique across the whole (possibly merged) store. + bmap.clear(); + } + // VoID/SD metadata accumulated over ALL sources + Model xxx = xvoid.getModel(); + xxx.setNsPrefix("void", VOID.NS); + xxx.setNsPrefix("sd", SD.getURI()); + xxx.setNsPrefix("xsd", XSD.getURI()); + xxx.setNsPrefix("rdfs", RDFS.getURI()); + xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); + xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); + xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); + xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); + xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + xvoid.getModel().listStatements().forEach(s->{ + Triple ff = s.asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); + ProcessQuad(qqq); + quadslist.add(qqq); + }); + // Set sum logic for backward compatibility in Stats object + stats.numGraphs = entities.size(); + stats.numSubjects = entities.size(); + stats.numPredicates = predicates.size(); + stats.numObjects = entities.size() + literals.size(); + + this.numQuads = quadcount.get(); + this.quads = quadslist.toArray(Quad[]::new); + quadslist.clear(); + logger.info("Dictionary created. Total quads: {}", this.numQuads); + } + + /** Parses one source document into the shared collected state. */ + private void parseSource(File input, AtomicLong quadcount) throws IOException { + // The syntax comes from the file name (TriG, N-Quads, N-Triples, + // RDF/XML, JSON-LD, Turtle; .gz and .zip handled) instead of + // hardcoding Turtle: this is a quad store, and named graphs can only + // arrive through a quad-capable syntax. + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { // Parse relative references against a stable sentinel base so they // resolve deterministically (not against the process working // directory). The relativize() step below strips the sentinel back // off; the relative form is resolved at query time against the URL // the .h5 file is served from. - AsyncParserBuilder parserBuilder = AsyncParser.of(xis, lang, REL_BASE); + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), REL_BASE); parserBuilder.mutateSources(rdfBuilder -> rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); final List>> spatialTasks = new ArrayList<>(); @@ -660,7 +708,7 @@ protected final void parse() throws IOException { } catch (Exception ex) { // Don't swallow a parse/processing failure - that would leave a silently // truncated dictionary. Abort the write; Error/OOM still propagate. - throw new IOException("Failed while parsing/processing RDF source: " + src, ex); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); } for (Future> task : spatialTasks) { ArrayList extraQuads; @@ -668,9 +716,9 @@ protected final void parse() throws IOException { extraQuads = task.get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while collecting spatial results: " + src, ex); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); } catch (ExecutionException ex) { - throw new IOException("Spatial processing failed for " + src, ex.getCause()); + throw new IOException("Spatial processing failed for " + input, ex.getCause()); } extraQuads.forEach(q -> { Quad canon = canonicalizeNumericObject(q); @@ -678,37 +726,11 @@ protected final void parse() throws IOException { ProcessQuad(canon); }); } - Model xxx = xvoid.getModel(); - xxx.setNsPrefix("void", VOID.NS); - xxx.setNsPrefix("sd", SD.getURI()); - xxx.setNsPrefix("xsd", XSD.getURI()); - xxx.setNsPrefix("rdfs", RDFS.getURI()); - xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); - xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); - xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); - xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); - xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); - xvoid.getModel().listStatements().forEach(s->{ - Triple ff = s.asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); - ProcessQuad(qqq); - quadslist.add(qqq); - }); - // Set sum logic for backward compatibility in Stats object - stats.numGraphs = entities.size(); - stats.numSubjects = entities.size(); - stats.numPredicates = predicates.size(); - stats.numObjects = entities.size() + literals.size(); - } catch (FileNotFoundException e) { - throw new IOException("Source file not found: " + src, e); + throw new IOException("Source file not found: " + input, e); } catch (IOException e) { - throw new IOException("I/O error while reading RDF source", e); + throw new IOException("I/O error while reading RDF source: " + input, e); } - this.numQuads = quadcount.get(); - this.quads = quadslist.toArray(Quad[]::new); - quadslist.clear(); - logger.info("Dictionary created. Total quads: {}", this.numQuads); } private boolean isGeoLiteral(Quad quad) { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java index 5ae10267..8638bc50 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java @@ -62,6 +62,7 @@ public void write() throws IOException { ParallelPositionalDictionaryWriterBuilder db = new ParallelPositionalDictionaryWriterBuilder(); try (ParallelPositionalDictionaryWriter w = db .setSource(builder.getSource()) + .setSources(builder.getSources()) .setDestination(builder.getDestination()) .setName(Params.DICTIONARY) .setSpatial(builder.getSpatial()) diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java index 51d37e7d..868ab944 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelPositionalDictionaryWriterBuilder.java @@ -3,6 +3,7 @@ import com.ebremer.beakgraph.hdf5.writers.PositionalDictionaryWriterBuilder; import java.io.File; import java.io.IOException; +import java.util.List; import java.util.concurrent.ForkJoinPool; /** @@ -30,6 +31,12 @@ public ParallelPositionalDictionaryWriterBuilder setSource(File src) { return this; } + @Override + public ParallelPositionalDictionaryWriterBuilder setSources(List files) { + super.setSources(files); + return this; + } + @Override public ParallelPositionalDictionaryWriterBuilder setDestination(File dest) { super.setDestination(dest); diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java index 5c8bfdc1..e0f031cc 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java @@ -10,11 +10,10 @@ import com.ebremer.beakgraph.huge.HugeRecords.RowId; import com.ebremer.beakgraph.huge.HugeRecords.TermRow; import static com.ebremer.beakgraph.utils.UTIL.MinBits; +import com.ebremer.beakgraph.utils.RdfSources; import com.ebremer.ns.GEO; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.ArrayDeque; @@ -32,13 +31,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.zip.GZIPInputStream; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; import org.apache.jena.graph.Triple; import org.apache.jena.irix.IRIx; -import org.apache.jena.riot.Lang; -import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.lang.LabelToNode; import org.apache.jena.riot.system.AsyncParser; import org.apache.jena.riot.system.AsyncParserBuilder; @@ -78,7 +74,8 @@ final class HugeBuildPipeline implements AutoCloseable { private static final String REL_BASE_PREFIX = "http://beakgraph.invalid/"; private static final IRIx REL_BASE_IRIX = IRIx.create(REL_BASE); - private final File src; + /** Source documents; more than one means a -merge build into a single store. */ + private final List sources; private final boolean spatial; private final boolean features; private final Path workDir; @@ -105,9 +102,12 @@ final class HugeBuildPipeline implements AutoCloseable { // ---- Build products (owned; released in close()) ---- private final List resources = new ArrayList<>(); - HugeBuildPipeline(File src, boolean spatial, boolean features, Path workDir, + HugeBuildPipeline(List sources, boolean spatial, boolean features, Path workDir, int termSpillBatch, int idSpillBatch, int mergeFanIn) { - this.src = src; + if (sources.isEmpty()) { + throw new IllegalArgumentException("At least one source document is required"); + } + this.sources = List.copyOf(sources); this.spatial = spatial; this.features = features; this.workDir = workDir; @@ -292,17 +292,35 @@ void run(Path tmpH5) throws IOException { // ------------------------------------------------------------------ private void ingest() throws IOException { - logger.info("Parsing {} (disk-based build)", src); this.pTempFile = new RecordFile<>(workDir.resolve("pcol.tmpids"), HugeRecords.VAR_LONG_CODEC); - try (InputStream xis = src.toString().endsWith(".gz") - ? new GZIPInputStream(new FileInputStream(src)) - : new FileInputStream(src)) { - String fname = src.getName(); - if (fname.endsWith(".gz")) { - fname = fname.substring(0, fname.length() - 3); - } - Lang lang = RDFLanguages.filenameToLang(fname, Lang.TURTLE); - AsyncParserBuilder parserBuilder = AsyncParser.of(xis, lang, REL_BASE); + for (int i = 0; i < sources.size(); i++) { + // Blank-node labels are document-scoped in RDF: two merged sources + // may both say _:b0 and mean different nodes (labels are parsed as + // given). Prefixing every label with the source ordinal keeps + // documents from colliding WITHOUT the unbounded RAM map the RAM + // writer's AlignBnodes uses. The prefix never reaches the output: + // bnodes are stored by dictionary rank and readers regenerate + // labels from ids. Single-source builds stay untouched. + ingestSource(sources.get(i), sources.size() > 1 ? (i + "/") : null); + } + // VoID/SD metadata quads over ALL sources (mirrors the RAM writer; the + // model's namespace prefixes are presentation-only and irrelevant to quads). + for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { + Triple ff = it.next().asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); + acceptRow(qqq); + } + pTempFile.finish(); + logger.info("Parse complete: {} source quads, {} total rows", parsedQuads, rows); + } + + private void ingestSource(File input, String bnodeScope) throws IOException { + logger.info("Parsing {} (disk-based build)", input); + // Syntax from the file name (TriG, N-Quads, N-Triples, RDF/XML, JSON-LD, + // Turtle); .gz and .zip are decompressed transparently - one shared rule + // with the RAM writer and the CLI filter (RdfSources). + try (RdfSources.OpenedSource opened = RdfSources.open(input)) { + AsyncParserBuilder parserBuilder = AsyncParser.of(opened.stream(), opened.lang(), REL_BASE); parserBuilder.mutateSources(rdfBuilder -> rdfBuilder.labelToNode(LabelToNode.createUseLabelAsGiven())); SpatialAugmenter augmenter = new SpatialAugmenter(features); @@ -316,6 +334,7 @@ private void ingest() throws IOException { .map(quad -> quad.isDefaultGraph() ? new Quad(Quad.defaultGraphIRI, quad.getSubject(), quad.getPredicate(), quad.getObject()) : quad) + .map(quad -> scopeBlankNodes(quad, bnodeScope)) .map(this::relativize) .map(this::canonicalizeNumericObject) .forEach(quad -> { @@ -329,7 +348,7 @@ private void ingest() throws IOException { if (spatial && isGeoLiteral(quad)) { inFlight.add(scope.submit(() -> augmenter.addSpatial(quad))); while (inFlight.size() >= maxInFlight) { - drainOne(inFlight); + drainOne(inFlight, input); } } } catch (IOException ex) { @@ -337,26 +356,35 @@ private void ingest() throws IOException { } }); while (!inFlight.isEmpty()) { - drainOne(inFlight); + drainOne(inFlight, input); } } catch (UncheckedIOException ex) { - throw new IOException("Failed while parsing/processing RDF source: " + src, ex.getCause()); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex.getCause()); } catch (Exception ex) { - throw new IOException("Failed while parsing/processing RDF source: " + src, ex); - } - // VoID/SD metadata quads (mirrors the RAM writer; the model's - // namespace prefixes are presentation-only and irrelevant to quads). - for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { - Triple ff = it.next().asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); - acceptRow(qqq); + throw new IOException("Failed while parsing/processing RDF source: " + input, ex); } } - pTempFile.finish(); - logger.info("Parse complete: {} source quads, {} total rows", parsedQuads, rows); } - private void drainOne(ArrayDeque>> inFlight) throws IOException { + /** See ingest(): per-document blank-node scoping for -merge builds; no-op when scope is null. */ + private static Quad scopeBlankNodes(Quad q, String scope) { + if (scope == null) { + return q; + } + Node g = scopeNode(q.getGraph(), scope); + Node s = scopeNode(q.getSubject(), scope); + Node o = scopeNode(q.getObject(), scope); + if (g == q.getGraph() && s == q.getSubject() && o == q.getObject()) { + return q; + } + return new Quad(g, s, q.getPredicate(), o); + } + + private static Node scopeNode(Node n, String scope) { + return n.isBlank() ? NodeFactory.createBlankNode(scope + n.getBlankNodeLabel()) : n; + } + + private void drainOne(ArrayDeque>> inFlight, File input) throws IOException { Future> task = inFlight.poll(); if (task == null) return; ArrayList extraQuads; @@ -364,9 +392,9 @@ private void drainOne(ArrayDeque>> inFlight) throws IOExc extraQuads = task.get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while collecting spatial results: " + src, ex); + throw new IOException("Interrupted while collecting spatial results: " + input, ex); } catch (ExecutionException ex) { - throw new IOException("Spatial processing failed for " + src, ex.getCause()); + throw new IOException("Spatial processing failed for " + input, ex.getCause()); } for (Quad q : extraQuads) { acceptRow(canonicalizeNumericObject(q)); diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java index 30201611..9d7660b1 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java @@ -3,6 +3,7 @@ import com.ebremer.beakgraph.Params; import com.ebremer.beakgraph.core.AbstractGraphBuilder; import com.ebremer.beakgraph.core.BeakGraphWriter; +import java.io.File; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileVisitResult; @@ -11,6 +12,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +41,10 @@ * node ordering (labels are kept as parsed rather than relabelled), which * yields an isomorphic - not byte-identical - store. * + *

Like the other writers, {@code setSources(List)} merges several source + * documents into the one store being written (-merge), with blank nodes kept + * distinct per document. + * * @author Erich Bremer */ public class HugeHDF5Writer implements BeakGraphWriter { @@ -61,9 +67,14 @@ public void write() throws IOException { Path workBase = (builder.workDir != null) ? builder.workDir : (dest.toAbsolutePath().getParent() != null ? dest.toAbsolutePath().getParent() : Path.of(".")); Path workspace = Files.createTempDirectory(workBase, ".bghuge-"); + // -merge: every source in the list is parsed into this one store + // (blank nodes stay distinct per document); otherwise the single src. + List inputs = builder.getSources().isEmpty() + ? List.of(builder.getSource()) + : builder.getSources(); try { try (HugeBuildPipeline pipeline = new HugeBuildPipeline( - builder.getSource(), builder.getSpatial(), builder.getFeatures(), + inputs, builder.getSpatial(), builder.getFeatures(), workspace, builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn)) { pipeline.run(tmp); } diff --git a/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java b/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java new file mode 100644 index 00000000..fd8ee92b --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/utils/RdfSources.java @@ -0,0 +1,104 @@ +package com.ebremer.beakgraph.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFLanguages; + +/** + * The single home for "what RDF source files does BeakGraph accept and how are + * they opened". A source is a document in one of the {@link #BASE_EXTENSIONS} + * syntaxes, optionally compressed as {@code .gz} or {@code .zip} (a zip is the + * zipped equivalent of ONE document: its first file entry is read). The CLI + * traversal filter and every writer pipeline (RAM, parallel, huge) go through + * this class, so a new syntax or compression is added exactly once. + */ +public final class RdfSources { + + /** Accepted (lowercase) RDF syntax extensions, without compression suffixes. */ + public static final Set BASE_EXTENSIONS = Set.of("ttl", "nt", "nq", "trig", "rdf", "jsonld"); + + private RdfSources() {} + + /** + * True when the file name is an accepted RDF source: {@code name.}, + * {@code name..gz}, or {@code name..zip} for any accepted + * extension. + */ + public static boolean isSupported(String fileName) { + String n = fileName.toLowerCase(Locale.ROOT); + if (n.endsWith(".gz")) { + n = n.substring(0, n.length() - 3); + } else if (n.endsWith(".zip")) { + n = n.substring(0, n.length() - 4); + } + int dot = n.lastIndexOf('.'); + return dot >= 0 && BASE_EXTENSIONS.contains(n.substring(dot + 1)); + } + + /** + * A source opened for parsing: the (decompressed) stream plus the syntax + * detected from the file name - or, for zip archives, from the entry name + * when it is more specific. + */ + public record OpenedSource(InputStream stream, Lang lang) implements AutoCloseable { + @Override + public void close() throws IOException { + stream.close(); + } + } + + /** + * Opens a source file, transparently decompressing {@code .gz} and + * {@code .zip}. For zip archives the stream is positioned at the first + * file entry (the archive is expected to be a zipped single document); + * reading it ends at that entry's boundary. + */ + public static OpenedSource open(File src) throws IOException { + String name = src.getName(); + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".gz")) { + return new OpenedSource(new GZIPInputStream(new FileInputStream(src)), + detectLang(name.substring(0, name.length() - 3))); + } + if (lower.endsWith(".zip")) { + ZipInputStream zis = new ZipInputStream(new FileInputStream(src)); + try { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory()) { + // Prefer the entry's own extension; fall back to the + // archive name minus ".zip" (e.g. data.nt.zip -> data.nt). + Lang lang = RDFLanguages.filenameToLang(entry.getName(), null); + if (lang == null) { + lang = detectLang(name.substring(0, name.length() - 4)); + } + return new OpenedSource(zis, lang); + } + } + } catch (IOException | RuntimeException ex) { + zis.close(); + throw ex; + } + zis.close(); + throw new IOException("Zip archive contains no file entry: " + src); + } + return new OpenedSource(new FileInputStream(src), detectLang(name)); + } + + /** + * Syntax from a (decompressed) file name; Turtle when unrecognized - this + * is a quad store, and named graphs can only arrive through a quad-capable + * syntax the name identifies (TriG, N-Quads). + */ + private static Lang detectLang(String effectiveName) { + return RDFLanguages.filenameToLang(effectiveName, Lang.TURTLE); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java new file mode 100644 index 00000000..ca376c11 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java @@ -0,0 +1,227 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.utils.RdfSources; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The two source-handling features of the CLI: every supported syntax + * (Turtle, N-Triples, N-Quads, TriG, RDF/XML, JSON-LD - plain, .gz, .zip) + * converts, and -merge folds a whole source tree into ONE store with + * per-document blank-node scoping. + */ +class MergeAndFormatsTest { + + @TempDir + Path dir; + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static void write(Path p, String content) throws Exception { + Files.write(p, content.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeGz(Path p, String content) throws Exception { + try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(p.toFile()))) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + private static void writeZip(Path p, String entryName, String content) throws Exception { + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(p.toFile()))) { + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(content.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + + private static boolean ask(File h5, String query) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create(query)).build()) { + return qe.execAsk(); + } + } + } + + private static long count(File h5, String selectCountQuery) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create(selectCountQuery)).build()) { + return qe.execSelect().next().getLiteral("n").getLong(); + } + } + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void sourceFilterAcceptsExactlyTheSupportedNames() { + for (String ext : new String[]{"ttl", "nt", "nq", "trig", "rdf", "jsonld"}) { + assertTrue(RdfSources.isSupported("data." + ext), ext); + assertTrue(RdfSources.isSupported("data." + ext + ".gz"), ext + ".gz"); + assertTrue(RdfSources.isSupported("data." + ext + ".zip"), ext + ".zip"); + assertTrue(RdfSources.isSupported("DATA." + ext.toUpperCase() + ".GZ"), "case-insensitive " + ext); + } + assertFalse(RdfSources.isSupported("data.txt")); + assertFalse(RdfSources.isSupported("data.h5")); + assertFalse(RdfSources.isSupported("data.zip"), "a zip without an RDF extension is not identifiable"); + assertFalse(RdfSources.isSupported("data.gz")); + assertFalse(RdfSources.isSupported("data.owl")); + } + + @Test + void allFormatsConvertIndividually() throws Exception { + Path src = Files.createDirectories(dir.resolve("srcfmt")); + write(src.resolve("a.ttl"), " .\n"); + write(src.resolve("b.nt"), " .\n"); + write(src.resolve("c.nq"), " .\n"); + write(src.resolve("d.trig"), "@prefix ex: . ex:gtrig { ex:s ex:p ex:otrig . }\n"); + write(src.resolve("e.rdf"), """ + + + + + + + """); + write(src.resolve("f.jsonld"), """ + {"@id": "http://ex.org/s", "http://ex.org/p": {"@id": "http://ex.org/o-jsonld"}} + """); + writeGz(src.resolve("g.nt.gz"), " .\n"); + writeZip(src.resolve("h.trig.zip"), "h.trig", + " { . }\n"); + + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outfmt").toFile(); + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.traverse(); + + assertEquals(8, cli.getFileCounter().getRDFFileCount(), "every format must pass the source filter"); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "every format must convert"); + + Path out = dir.resolve("outfmt"); + assertTrue(ask(out.resolve("a.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("b.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("c.h5").toFile(), "ASK { GRAPH { } }")); + assertTrue(ask(out.resolve("d.h5").toFile(), "ASK { GRAPH { } }")); + assertTrue(ask(out.resolve("e.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("f.h5").toFile(), "ASK { }")); + // .gz / .zip keep the inner extension in the mapped name (last extension is replaced) + assertTrue(ask(out.resolve("g.nt.h5").toFile(), "ASK { }")); + assertTrue(ask(out.resolve("h.trig.h5").toFile(), "ASK { GRAPH { } }")); + } + + private Path mergeSourceTree(String name) throws Exception { + Path src = Files.createDirectories(dir.resolve(name)); + write(src.resolve("m1.ttl"), " .\n"); + write(src.resolve("m2.nq"), " .\n"); + writeZip(src.resolve("m3.nt.zip"), "m3.nt", " .\n"); + // The SAME bnode label in two documents: they are distinct nodes and + // must stay distinct in the merged store. + write(src.resolve("bn1.ttl"), "_:b0 \"v1\" .\n"); + write(src.resolve("bn2.ttl"), "_:b0 \"v2\" .\n"); + return src; + } + + private void assertMerged(File h5) throws Exception { + assertTrue(h5.exists() && h5.length() > 0, "merged store must exist: " + h5); + assertTrue(ask(h5, "ASK { }"), "from m1.ttl"); + assertTrue(ask(h5, "ASK { GRAPH { } }"), "from m2.nq"); + assertTrue(ask(h5, "ASK { }"), "from m3.nt.zip"); + assertTrue(ask(h5, "ASK { ?b \"v1\" }"), "bnode statement from bn1.ttl"); + assertTrue(ask(h5, "ASK { ?b \"v2\" }"), "bnode statement from bn2.ttl"); + assertEquals(2, count(h5, "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?o }"), + "_:b0 from two documents must remain two distinct blank nodes"); + } + + @Test + void mergeCombinesAllSourcesIntoOneFile() throws Exception { + Path src = mergeSourceTree("srcmerge"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmerge").resolve("all.h5").toFile(); + p.merge = true; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(5, cli.getFileCounter().getRDFFileCount()); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the merge must succeed"); + assertMerged(p.dest); + assertEquals(1, p.dest.getParentFile().listFiles().length, "exactly one output file, no per-source .h5"); + } + + @Test + void mergeWithParallelWriter() throws Exception { + Path src = mergeSourceTree("srcmergepar"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergepar").resolve("all.h5").toFile(); + p.merge = true; + p.parallel = true; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the parallel merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeWithHugeWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergehuge"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergehuge").resolve("all.h5").toFile(); + p.merge = true; + p.huge = true; + p.workdir = dir.resolve("workmergehuge").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -huge merge must succeed"); + assertMerged(p.dest); + } + + @Test + void mergeIntoExistingDirectoryWritesMergedH5() throws Exception { + Path src = mergeSourceTree("srcmergedir"); + Path out = Files.createDirectories(dir.resolve("outmergedir")); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = out.toFile(); + p.merge = true; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount()); + assertMerged(out.resolve("merged.h5").toFile()); + } +} From 9d2bbece6118cb9763b60b780ac8a04b0c1c152f Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 14:50:54 -0400 Subject: [PATCH 06/17] hugeUltra writer add documentation to docs folder --- docs/BeakGraph-HDF5-Architecture.pptx | Bin 0 -> 44319 bytes docs/INSTRUCTIONS.md | 151 ++++++ .../beakgraph/cmdline/BeakGraphCLI.java | 164 +++--- .../beakgraph/cmdline/MethodValidator.java | 27 + .../ebremer/beakgraph/cmdline/Parameters.java | 31 +- .../beakgraph/core/fuseki/BGVoIDSD.java | 9 +- .../beakgraph/core/lib/NodeComparator.java | 17 +- .../writers/MultiTypeDictionaryWriter.java | 38 +- .../PositionalDictionaryWriterBuilder.java | 147 +++--- .../hugeUltra/CachingNodeComparator.java | 56 ++ .../hugeUltra/HugeUltraHDF5Writer.java | 178 +++++++ .../writers/hugeUltra/PackedLongSorter.java | 345 ++++++++++++ .../writers/hugeUltra/PackedQuadSorter.java | 123 +++++ .../writers/hugeUltra/PackedRowIdSorter.java | 92 ++++ .../hugeUltra/ParallelSpillSorter.java | 333 ++++++++++++ .../hugeUltra/UltraSorterProvider.java | 176 +++++++ .../hdf5/writers/hugeUltra/package-info.java | 35 ++ .../writers/parallel/ParallelHDF5Writer.java | 2 +- .../hdf5/writers/parallel/package-info.java | 2 +- .../hdf5/writers/ultra/ParallelRadixSort.java | 143 +++++ .../hdf5/writers/ultra/UltraBGIndex.java | 490 ++++++++++++++++++ .../hdf5/writers/ultra/UltraBitmap.java | 97 ++++ .../hdf5/writers/ultra/UltraDictionary.java | 267 ++++++++++ .../hdf5/writers/ultra/UltraHDF5Writer.java | 155 ++++++ .../hdf5/writers/ultra/UltraIngest.java | 446 ++++++++++++++++ .../hdf5/writers/ultra/UltraPackedBuffer.java | 101 ++++ .../hdf5/writers/ultra/package-info.java | 38 ++ .../beakgraph/huge/ExternalSorter.java | 27 +- .../beakgraph/huge/HugeBuildPipeline.java | 252 ++++++--- .../com/ebremer/beakgraph/huge/HugeIO.java | 7 +- .../ebremer/beakgraph/huge/HugeRecords.java | 24 +- .../com/ebremer/beakgraph/huge/NodeCodec.java | 7 +- .../ebremer/beakgraph/huge/RecordFile.java | 4 +- .../ebremer/beakgraph/huge/RecordSorter.java | 34 ++ .../beakgraph/huge/SorterProvider.java | 57 ++ .../cmdline/BeakGraphCLIConversionTest.java | 46 +- .../cmdline/MergeAndFormatsTest.java | 40 +- .../hugeUltra/CachingNodeComparatorTest.java | 83 +++ .../hugeUltra/HugeUltraWriterParityTest.java | 214 ++++++++ .../writers/ultra/UltraInternalsTest.java | 218 ++++++++ .../writers/ultra/UltraWriterParityTest.java | 308 +++++++++++ .../beakgraph/huge/ExternalSorterTest.java | 4 +- 42 files changed, 4695 insertions(+), 293 deletions(-) create mode 100644 docs/BeakGraph-HDF5-Architecture.pptx create mode 100644 docs/INSTRUCTIONS.md create mode 100644 src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedQuadSorter.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedRowIdSorter.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/ParallelSpillSorter.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/package-info.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBGIndex.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraBitmap.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraDictionary.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraPackedBuffer.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/package-info.java create mode 100644 src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java create mode 100644 src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java diff --git a/docs/BeakGraph-HDF5-Architecture.pptx b/docs/BeakGraph-HDF5-Architecture.pptx new file mode 100644 index 0000000000000000000000000000000000000000..bdfcb374ec5dc763704b5541a9dc94aa4d40a207 GIT binary patch literal 44319 zcmdqIQ;;CtwzgZgZQEV8%`V%vZM&+=UAAr8wv8^^u2bK)_J7V=>+FdAN9?0XXa1A|20;M;0Du5cwmZ;L0#lVZ1Oxz(K?DFm{(h<{WNYJOY~!S>c)Z8iCO)MlLlA>_&%N&p=fDpGcihL+}Nrl`08_SZcihPhS!A27I!0o(1kj-C;+ zdPsR4l3y7wUvk9y6y{E!IYvzYp?$|?>=dCQU~R0?GxKH7&Trm!Dn`exHZ9CpWd@LH z8L@m(HYsQf`9{&oymwnZ`Y>mh6fuRhIp)R&8GBhFHe)o^U)Pj1{kzvei=v)0K0BJx zUfi!>$h;}M?QS_Ia3tNxoYP%Zdm80bM@y3Oj&kExx#?nLz)Y!OSXheFsD+73fOD|qK zc`UCyOy3U2y>H(uf}aRi4hH>JEU4+6@L?p=Iu4|mo?5ur-E8Y{FMLg~iXf5<3gM8A zI3<6bCiU5y(ox6Nb(A2z@^~;{}?&BH4H@408RVTZ_9a{OupG zcyp22nsDN%1VI>NAz?Egjy^yH&cBWD_sC8(o|N>&B;>Nt|4dj$DB1X zJtIsm8SccPeIW(IlnO1OB2r8K9zRrVqdSm{hMt^4=j5%p9ntd_uWPk@l0J zL%E%iG%(a7aVCJX!9@;ksJ*=Jv8L|rBdLRrkG82uT)I}q9(Aj94MLjC-zL}W{sj>8 z(L>9yums;VAZKP_9ehR#7^7)EgHKG)?{5r2w8HKi-NW3nM8yc^O8N4~T7uN^`53-A zp1>x}%fBXgCz*1w;GlZLMr~#6@si^Lr~mh*=6S*^z~_U+_M(7$>ed$RmDmeO(L6=b z?12NOF6ljYZhbTBX6y%X%^LVK-wjdgZ(m|O0BxVk-QcF^tC;r}{JVck=&YF-_p9%5 zvIYSFK=|iz(zmnwYnU>XdSm7p5ITu(;FIO-%n}5HfBrE6)vol&VOFRgcdsFj4mK2P zIW6SSOK(8 zQ}u!g+&!w(h0y8>`&#mYp{_=MvK%8_IUYn^A68%1TqY6f7xx{4BY{YYTk@B;;)S>Qzo*U&TZ9hQ|5Pjxv?t26{}m6^T~ z&jZ|yu_874_0Id9>b|XVE?jCSW{l5VVUJE>M!baUKLD>tZ}BQf)f0w4GR2mGvz=P6 z=*Zf6!*aK5QC;+Q!>kR2u~pghrctnF$*sGC--_&-5$sp?jDO-#U6&lIM`?b)kQA(^ z&};01x971G?_5o351u?MnlITJ_}$DGPu-wH*4#2r6yeg=)2_NH-@g^HUM@qQ<#z$k z!vX;OC5?7=PV{yT#*W`QsPANMYx7rvT%=6e>@gttUA>{i(c`Ev+mFx_DXmrgQZXP^ zItFeT1(3%juY-m5er*gS3@K5-Cum;To^ZR>*VKRXDl2gWp&LsxCVoGN#?f=vPP9si z-eQfkXLJZ6sUT=D`4v4yAUb`AUvIKzhtNY3M>Q|6{QGx2%tfJ9Q5E$qkCHI=A}qsT z6H9fNl2Df;wCrc;DXO#gbmT@vB222+XXd*{JFVlh_ED%LtuJMtf0K8Z_iC7B<004L zd2@@c#4q9s_$#1;JQD>Y2;xv2qYnjDPKk;!ExZMx5N>8wUJ%+(Btri$%49*_33lT+ zLVR%hU($I50&v-*-R*;`g`uD4+pLOFyR@% zH9JNcMvWl)w>EK{w1WaU;MPijnhrS*z~rsTrcig7vySDcY;x7qmp}7j(12KdG@CD0 z3Gc=2^9`u_Qap4yf-coUC>)d~LOQDXm{5Xht5uKUn+W-n&(Xiko{D)vCty(5$_p3d zHF4GGSen}q%8+E8GU0cm$FDn*Bh!)x^9mYpj{||}6S>6lR1(OoG`b=!RINL$C8{<) zDpr0=;z|_p;{p7F9vK845h>nRKC`psjl$|ybWCD+zTx6an3^`$g!7?Oh|12n3nc#$ z116wKE&9_8Qu3Vy6GXcbLqMK<0{zH!cJw^YP=gy^Rxj+UK z@auM8(A_=>sJC8Lrl!artGxp)FW^qsexhdSO|man)=r!RcY{Lxwbd7AO*kg_w&~WB60dP&}=?_c`3oATr1`3`{kjtgI zn8a5Qv+AI*i@CpgE~E*SgrafF;>FWC=a5E7B9QXhc;02Ii<`$4buVsLp5Q8jzV6XP zdKp{LS2ck*v^WBe)BaqGHv$^3D<}s@e8|=|6Tw;gW^0+sXw?-MY~Zzk8=g0wL0dEY z(83568~Q?$-Gmb0qa^ffa)fLzN|hQ2?|lQf(dI1!Id8f8Rv1+_A6dTQXYl;>03-FQ{;#d8$wTCS9T}|*F%;qNkqQ4V?f*a6_}c*d z#f--I@xQcR|zgefD^Z z!}vWA9Flu0#IFV=f`SU%(yjNR`#VH5cgTh`Ed@(crSFlp7zF8g;No1y)@^k z=t=&KD6u(HT41Ilr_e|f7g>om#pD6|SrXzxbmeN^)XaVhKg`H5yI_C=q)BJ1X!UDW zbA`%_|hhyv5S%J%BF|0CJ$qaqHU?C*; z`bn&)Q5Hx87|2?$H|wEkLF&WSQZlae$H^?Xt}M>?Fk*dtZUN1r>Wl3n#ip0hon#V4kZQtF;b|`Dh)}wz3;U|~o-8E&2vbp!Jx`a`PA1w&jHxmI*V{{vc zb}}2Rg+OO#xQkgtc5FZ$}XD5 zo)(>7bsgTT2#Q%^eo~l~GL1;HJ^6v)zjoO_5Z^U@{%=@IfUZ8tICM zJYc%D@h1Vp16?T+3zUo%zB>hc4n*zJIYJX_Xd^c-vwk?#&_8HUxy>{i&ka}8RWIVh zN=X~AwE5mpoIw43e@-6@GtfY0`Z#`!xr}ya-RTZIcy!uKQ@$7joV)9l*85pMYRa09 zDFXlL7uniO)3j8%!TK5k^68vEX*omnG7Pb$+tF-nJXGlnu${WT%=^r&v~jHra5a5y z1oiqFd)26qnC2(Uzv%_yj5ftgMPl4L4?)}laxx zpC9bflgHv^a=)+gy8#dT%Rb+}(SLqED&8;8U+M6Ejzy(#em-UMzFjQSPx2Q6>w0}0 z#G1}?n?0KOCA}D4WP{WbL*h?DnKPjmR zY-j0(z_!4ZdR%Au(dhx{;PaJGbl^wS=7H@@GL!+hvYX=4MX_#58=vT44Ew9MV4St^ zM8V6{wL9KiL?ervS{SUtN{CEqgAon`hKCEDF!}I@`JeL^qIO^Yl(QQZgW4=E2`rr@ z1?E&T$-`z2XpxqN66~PvT5S#G%t&3aP3WgWDg$Cdmg=Lj>4+JI5)+NKA8ynOJhS_^6E)bO<7m9Ti+k0#UuR9~8|H9!aKa zzKj^X{dAe+`IMY9w3m9dGH!S0-8LR9xBbc589jVM=RfAQUAXOf;#w<3Uw#yMHCgXs zbryOVCDU*Z!kZU$Q4UGmY_v2&`upff|3|u!@E?CZNPV@i@*N=m`X5MI-h#?4_wAZ9i)tPK^oGRe~BO`175n-BNfut5t~rQOJ5J=EPsVF^@6+9O)m z)Hru^W2JK*F-h}yMB3DyPaFbG+v{pCDoBMsnh(j|%+fAd2)jLsow!WVzKWi%K zY#jduH%h1<*x<3O@J5Rw+b*#i2E&HWyso6(pYw%=ua#EacryXQGF|_|(pXe&4SHr< z)JKI~Lsn(e)N)C;>3-O}24aIr@ewd26Fh7(JP)`Xc%1K$E%qxcMMLBzDN<;rwGcC< z?zLK0ppx{aYotE^@O%i#G@I`7A@c&u>muvY!n0P``T{S0674f_uOFPi6T6DdFu8gJ z#Nq7Q_KNF7 zYe{a03Xn_Luc3hVMpMf67x;fQ2P+@Zv4!6bx#|1(tC#tgIdIZ9urgM3auC+0%2@ zQc2t;-V&JxCU~l3dO5zPG#hvCowzo{OREtORlhpvO&8L<#VC?GHsvg)=NWNK$UxWT zAn6QFNPvDo3e&d)YO&d&F^^vydfsGQMV>HAt+>b0pwpx@BJ{E+<@m5nkBx~_XA@VV zQ~>>WJ!46d?d>{6H=ojTDh*0&Ko z-7o7viz=1(d8?$T9+S#deYW$YK}G5@lmLOX@L ztmPM4Zn|8o&Nn-k)sT>p*05mw@`1Kx^lc7a{{hXODUzhz=m0+{FqCm7?O>wqkXGZ~ zs~CoAYn(gVNkt!VP*isdWTjYDBBO1;@K|oj(R|P?Pjd0EEvaL^iElAWFB!k9$N|x zO^V5!#l2eJ?-YP&QBZ-tSfq}-vmoHJhOpH>?lJl%+z{yG#_m>ix1i=!6nRaInG$?e z#==+Pr#k}Ah;w!rl{7*)@Yh*v89KN^>94LoUqUymZNeQ`xk5-AZ1pn^fN0l+iMh*@~@Q0?%*aCrSa% z_=M;b*{r3}T_lab**)FS3!+(9)e7tIVWA_-*{has!SgaEJY9dXB5=n{5m2?L)9Xrs zv|HyDKWBcSa7zti9%;r1+*a}8j?$#Ckdj=ev>8-wYjDJC;BG2rof5pF zW5!HPZHC=%iI@t|M=+egPT#a|nT3hvGo=jdBYv*3{s4?4s|Uq4tuNHurnjWXNm+ zAE&9SYh%S$8jOsrnjhW7TaAtkJ5J&eG1V>!WOh9OxtpCGSn&Pps_vsP1So;8USl7f-mh#dyF8OnFF3_jNLnH$d2bl>lOXkgflIciGuvprjxski?_m|@e@EjtY^ zI28Y$0!^JLqU`q=n?$Wy8m_GR`+%^zQQP)ztIWKxP|z>zVSVwAmQm!HN$$ob+4W)Z zmgkt{C8NXMDLS6(E8M6#Yz!F_@!4N_gd%~6GDyi%J7!=Z0Wijc``8h-6e~Y#auI|G zLy$0_7JhR~-(uT^c&scn?D$qgnkX$}lpvwscd3m%nFk7oDm>wGeL0IySfG9b`aaFQll#oI*gQBvMk!srnPKNx-4U5aP0)zPW*g~y-(vO1-u9!{Y<{Jjl`Ot2uCvjj+Ou)tZ50@uzNEzu7bRZ}Gx^WY zqF!YlAqLm5%@3I zk>Hm*v-f837_qc3yLbS2ErWp#ZUiYo&%bV-r@P^i;pX}fopuQD;NUQ0D*v1!TtjGZ z_rc-WAB%`_e}ZuLp~afr)p!o<#|{%)+=Owt3Cr8}$i{=EeBzEG@X_@P{cJe=Xqr$hcyOOM|{;|+kQHX7FCyZuWp z3t@*sPW+MF^rfBCJr6|4nWV#yG?=0(t3A4P&g*cn+-HZJ{A3avD8W4NXJuRZXQJ5%tF^XQ35tg0;dJ>yK5|LM!ZqBuZmr=_V=4^+6+LNp;kO z3&pX=ADH6_K8@}R$u2O$M*wfj0y{#qG)mj#d}q~xbVIfup#SQuq{4ry_I$ToDq#Qs zi2gaO()#YU&QAa3LH=>`<8Kqvs5%>;&WhrFrD}E@n5_l~7R7U80;9v3xoYHUL4E}_ zVo3%hFX3|T#F(&c!AGt;M-8a6epc|f;QoW4DrSo32ui!#qmvR-Ly-_#C))EedS0ku z%uPp0Zhx!Y-9n6q1hv$m&rM{wlQyYyxw*TeOf3G-lhL>;6reDvn2{B2QkfXWxRrzy zk*%8ywOjK}9tjlQAo3LG3&;#1@|ns)m&Tk~&KfALDLP_W zOHbTy6!L?FKPrV1j_C3vbac;*&%)<^qV44Y9AQOP$l~HgJaLT90|n=@-Di?H+wO#Z z9RH-Ah6Fq8Tp|bqV!IA7BsFReS{vZrK3&D%fr2s7ng;J-!6*6RU$X zvS6=tp>%0v^K?BUUpCRwkwoBKRKBpds6#VJ`1<9=SMt^sr()Sqd6nO`OCLdTd^sb# z1K3lgGbw7W@+8c};_z2)7P{*bR^;NtkzwWszUDH^I10)l;)@37C1*mDcuA%m2M4zR z+CNOi{CXaEAUWj}rXJ`vTnD>DsG&)*G(WWv0jZs)-iMDakxZ+~#aAd94Mk*U*Ogy( z#Zz{$+W5sFXUGe0d<197Y|=vJM`v6zAYM0CV$XBI|6qsuAHfp)A`G5XnH5fT0J$73 zoi^8waGhJUw+-CTf3)lxv}-?!?nC$PA4)9SyO334bT;yi2?6Iz@H(r(0;% zk^4t)O+93bvuZyYTW1|5BH7ljSAFY<0_GzvgAj{n-I=8kM~JPrZ2Px=!>XV0Zdmho z-Lrn9mG-}@|9`^jKU3guu0~IH!u1g#fM0JG6VONtp6NwVUKmK@Xhre%fL77gg7)Rk zHmA&z!Noj1yd9-ovdEo>hihGHEYu;vbV4q@2t=+wv}{3&Vs->mx-INQleD=kYS{&7 z*_b)SsR?kK^B&?nNycH?gT)i((I@a0U^-CF=Meqof+=BDZd5*5-?`{BA}@F6TurM# zknv-hUG3%@dK39|w5500w4?+?Rh_-s8<#|0Ou86j9JxOMo z5Ts=!7b}!N2O1al4)F?!M()0AoPNO1gLx&XyKHxpzi_&}o2fBZ6uuECBX)^x3SyRY z(@~-_KRX<{Fhor74+IwH^CacFs&;o&lE?pHGn!_DGDA+v_*L4dQkKO}QO`mf?$vLp zabhb)Cz*F&uGXvdYkJLTH0Ow&kVZmYJ+q5lB%3}(MD&PzJ+VQd5>WtC9;-_Wz45$q zu~_Y!fooB-K2Q=DmYj`BxfqRph{&2eyBex%cUHX)KjReSxS5gkdLuH@X2N?_MqzfC9+de=5;heQCl$eg&Wn-_{k`v?(>dA{ z2`tI$^;tEyJBiXOt99c-K7g*xW{1krHl$p+&m;&o0#$i*)VHtC6F^XxKbbF^FL6mU zveQjFFY~e}^()l9Nz$&a-Mn8s7zAF`|wGYR!y&wbb~Nsq_<-Le0v{gHTt=bJ%m- zb9+3AP*HmhQt0Td>i~0MPz>W$UU(1>`kBUQSJntF5c{&SeN9KN)HST|)>%3o5lGo$ zY2`iQHgg;}^{en(^@o4Q+Lu6IQyS(LLE~dY0?|9^9Gpl*rQRgBYM>N!-gbs6H9O~G#iu-E=$-uv6#oE0^V0kA3PB--EzOGH_zEHfPF2;hNW zqYKt$M-LzG+WBWEAQe;|86$ewGWPW=@K$Z)jPc$Bc!yb!r+EK3a1>ZiFQVSLzE`SW zAfO#Pz#}&fX9^@c4uF`LAMt^%*`x@t9yfr^jE>ptF5vYugdK)3x`xw!8$Ny}o+FB_ucj|-@Cu&qQZ7HHvK2e zo)N37ou2N#x|v}V5LZ^dC0Dq7{6vOc2HCNtujX`Pc^QSdMg?oYmUW^tl_uRi(eW!V zF;>qD2c(Cr!c@8wy9sD!U{WC+j?g$hHqkv457U?9k2geck%z8{U| zp4!Z#AS~ARSMJ+>?Z+a}I)AfvF+D0pn;zcsGc_U&y(rT)vkH)Hz41=S1-C*3Hd@_aOp;fqPsy!N9rPNNpHh zI02_12+;6%B3*4_}-nAS;RDz*hN67V(L1LSK$3(p{ZEYD~Pl-A@-Td z2GlaDE|B*2a=dAM_!nj^poG-}kMePo^HtHmIu4j5BB>h;EkT)uiA~Kel_YO4gBYvr zq7N|lTV}T<$t())%UeQ&`SDqtWb}93k=2Oj*f)Lb$9z_3hd|;LdrSOlmfb$ggJ~-+UrW))f{^7L5EZHg0fg znB>fsu2uXyv?uUY?oMAJ*E`z0`mi41cKj6+l(9FePe7&+aX)#>d38ps)jkCRLQcG- zU#&enBUw$EZp#dHFe!?Qd%c1}r;O#2*T^bXFv(p#8Y0a^3pGk{2OopeLt%xE-6-eU zTZGJ&A^S;xf~EU`05pFle0+db4LGBQ63*0iqldZbL3h--JS^4`jzW#^M<({!>s(C-fpMDH^=BpAD$6iNIpf~HS1 zF@G-X=7q{EBVw5v`Ow%oYiz}nEsuZ?bUcp5r^*Ay=pvKXO^Et&hmNO0ijzlGh-9t) zsY{&8bDxBHjf0gOCow$|5f2o|cJ_LSNI#3val9lU#3vzhjV_%2s?MrTS5$R0*p@5R zw~`5_(KSi}6=05&H7wDDUdf!w#@VLW__}}AjLz5PgU?@nUE?~()|aHT2~z<&8%{Fn zgj8U^N9Oapb=Se;lCDnTz+{#kW}ZZ+V1Jyx2>h;Jov!BM?34O6W(MB(1ky4dnoPZP z%0b0-lJwx57%c;FUH4UI8PVofD;Aj#`(r;rL_6cj%ImBs^!n$FWKhI%n67PwxP|9* zp-cD~Sj+rKASAb03B3={{}xt%SL}ZdtRnR;Gz`COq1yk2EoAzKEnK(xYe{=s5#lts z8btxg8R`@z8OIuUj-+HBT#sZ8O%hjhuBtPaU)RzzgLhP1vJQvMd~*ugfmmU|_xTg} zLkbwAD4MCo?D!-N#=Hi?#h>RODlt)Rz$2Aq_Rs(#;e z_?$7(Mr@Yzpqg1cbs%BcEm4+k0dtNCC+4=tTq}t6cO^&>?B-;6qAarRV9_?f|qhd;;Fg^VY<0~Z4g;B zpnIGN;6E4lUtRW9Z+J=Bizc%qrT5y&#zBs~6)81fj`&g$e$OENGkl2&xgjnXo_B*< zd^rgvC#vo*smipnm@UdcE2{pOL&41r$eZVND$NtgK}$YFa(0Ugrq&KAnQRX&G3S*C zwkwItF7b{nzh(SuPTAvr^ziQsFi$t&1HA6dEM!}T9a!)VsPmh* z3yzx*;4L%w>MKxs$8Hh1C9hx|r1OY3-wxms!QyIw_g>r`(^Kx#vv(Be+nlNP;*z^o z24|e)-&1cM@ZH+iy@tjw0<>UU-BW*}wbgG@(8&`hVR+6MGoDp2h^8ZokLM%}5=oGc zMa%YLUj}dyWNVfBhP&bkHxh2-{B?FSIkf4$m2lu zDLij6f>jPi?xVe@?eh>o^)oKxlc~q4!!K8~K5k#GRCRg390RmK-!`kcJ*!%dO@<;+ z=YFxrc#%UIQa8z4Se`z(7x)HWT~*ipK+ zi)UKUwK(dctzZ=Addap{zx^c2D?kKoKy%%nBoO~gr=se{H+Jv%q@ZZRT+k-1Lon!< zdDqX_i@V3ovK&2$mJxN1^A=TE@L!C^>N>n|T-in8xGsLbS|%8bNikiPgq^muzaE>g zAksGXSTx0RC?tRM2wBn@v-tfia8%HvD49b zaQ7K=XHEY@iCOO?|4YF}oIO{K>S2((TDV^&vromI2ql`MvpQZ&)yQm=+~w)I7!)Rk zelf;Un|gj8a*{tBy77m%0f9-O67yklNl^wuCsj)KorS`tqvKxxZB`)nJj{ax^b(r| z#s&@}^z-?%Vf{ehVifvo88@Jc(0FZ+#+P8HmA%n zDR)28GzZa@VSi82keV+B`0lI&tB1U2QNbnf+U&4O@}P^>DVY*hGrZ*Is79l?=WP*Z4%j z#^?v4t#gH3G_G7@uv*oA7_PtqT!IfNDHhIqOEeqbMJ^UHji98gl+&$(k} zEe}1et>|E{w?|5nRmL`I?_EUa4YIEtVMKxP~b&p&NSQh$?Js@ z-d>nR*_^Wr+H*U+`yl04PlC%ANhi4r#J}73j&2J2-esVnqI>WhyR3&%fYRY7T^I{wN zfNBF5qJ+G!^49|tjawB=c%IMBpuUk9oun1><7gohRo^`o%cpaatn`%I6a+JV>E8E78(T;9_~eT6m_ z)qL8GC@$%UpPzWDu2Kmk!R}LoCgo$}!<=Fo5F40?+F#)RTaNw>lm8r!o)iqk?tgQX z73Y8ED9hg*Ro{%y`V{8$F#a0sm7O;bxsla?2} zQ{1Cu5P3~&>>jnsXRrfyw}0M!a7N$Y?`RfHf^LEiJK@|+otfjsm}PH|bdK)K^m1Z` zK&K2d<}&U%q5HV-t9;ybo!qgucR@3uWCRa7B0XP)!}2WCxYK^Y+_^Row0f}?(ew*` z8cmx_Tt@~?Da<#~Oi$RwpDlVUnegDoCWLLwn5D&O(FkBj)c!iXlTf(Esx%8!Vdx{3 zZuL+kt)&o`L#KCQ#_ya7E#`BoL@{CFfW4+N2QsBTNLqIoQ% zulbgwfq8u!dcxWRlw|d04qn<>`rVqk!B+8&p!F@u)gO|}A}yFfBGNx@$pMnlzoYnQ zwcA;(Z6eT56(pI=wIhr%k4e~g;hm#RiKHQ?R4kYOK7a3pNtyjU{7M- z@c6I9#m_t#vm~{GX$^z+tWL<92|yDxOmI#^dQET1^5|UD;OfI3%?3#}xy?O+YK}9s zQzvka8mjMDr~ye|k%p(r(3!oLOY4>}dzVdah+}dzVE3$*sgx{PDzytZV=Vyiub`pk zESJazLSZZIJoi|_o^^%P`a5H*Rr@zuAE+1ic)(V4gPjQ#I}f<@zQWR2 z=%Jdt{krfIfwiguJ8S)vAcTHoOj{F%swg=V4pB7LLc-bBYsuFCQBtjw*ZNDv79rIU zD~^&+#*VYfY=Sp<$ZALEpdH+70#b`0GM_fK^P|XO#T=jhVlTtnsy#Yh4Tm z+_<^yBj;QN5#Q<8KhRA7~HJGdvyj?`iMy+w-7 z#a#JEwkONg&+5aht#WGxOz!Zp#|KN;B<xMi3p$_k@!#V{FS}85$I02P&rLF#vdn|EfMB&+3tye zE(DdC0&W0c6=x%s{&?-c9w53~-0FyUrOK|9v9xi=az-JAM?`e^dPcFjTNdZph!9{LhgH+6J-*r0>>k3&j5{5v+enM5D^4%^JhE)mBtn1ywCg zKp@EJ6&7TYu@GZf1HVQBp2b&ht(6er3GVrTYpvJ7m#w~c*J=V{l};_baQ;T z5TK4IeofEBH&^Q`*Ru0W4Qbh{54mRVo3HJXXLPb<#*Kmz~Nl%a6Gs2uNkANq(832yPZ_mze}uK#H(eRRN1wXXLbTC%cg)Vm5Re zPdD7Tp1uI-nXL5tu*D!T%9D%8^C$t{)QhD29qDRCL;Y947$V*}b>TAvVQQs#^dHFm zpuX;aa4c%}2+}doPq@-EsFx}(VHUd5&7sJrf%-fOCPu^6HZmm^36Rp7fZIQ12X*5^ z+X>OoM1;}dpY0@#`$ksV%D=bPlkJhQ8QdO0ewjv6HB(geB4EQ?C%%g!wBvN@W58g)p=q&;XBUqz2q-caD%N z8|`a3cf~A$shBg6OZE9fi89g5=%r~0$mTflCZ>489utyN6iqo5`OHFlyK|?|7e+@9 zP4JYh^yW)wo=hhV&UAjneiMPr2`5PQKG5=azdBYf zwmWvF{Me4BFRCrEY*XD*9_YKn7vh67Bzq28iae30XqpBGF3WigE`Ss|U*LO_GhB`y z#V9b5^O@mFyalZT`_eW6l2GxJ9J73V8OZ)I6Jkp8PS4GTYmcpE?$*r*MI;x!iBZ6M z)1pjdvW1OSA)NlVuB~)Jk1>J6_kJ0jh9$jxLO1qCRw*^V`}=? zxHh~>UP{vAf-^i6PVZr8^l~h|?aGU*@BQ3D18wb3IWED1;|Fb-vr9#)t|Jo~NFN)H z2*W%ipOhP*ma3%CtrJJYb;(sapnu z77<dAY){{HWI?ude2w3$Fbc4arq2zQdE4{| z=3~4a2O4WmMd@2kF5TeiQ#Ow|lQuw(mI|24^(`s8E86U_9P~9{OY+WV#t*a--Heq) zM0l7!$Xi&m12owW6*7D&N**a-FZr!J9P8PT$FG9osCgG$$?XZq!*xL5>@pPqZZ454 zF~1v`u8oE3l5jpqvWOIv3YmzY9!hsLs3QP3BVAC-D$O@Ln;nQI{#7YfqRIKc1y=y-v}&QT&WBEf^UfI)75;&!T1f z^|{AYbtNgFtGEpc@NA=bVza}^pC~Bzpqt<&W-g|(9WzvB-aW9^%eu6sm&v2n679^Y z)&g#6ZMX||hS%SQUEvCIW?l37qoTVPY`CTu3>&XMr23Mu6%$miQ+=zo*a)te}E zrW-l4>m#`z_MD9>z z4&Wdf@wr$&7 zNjm%O?sNX{u6_FK)92Ifs#!H_ewKeyA>e7=7?gEd*2t1$wf zHyF2?8aO6J@D)%zT(E^jLG%oI&S%&}!;Z1S&iOIKddXdEy2i_)0O*v$7jZ_eHC#P` zb|T&r809GRjr!;m6`Rl*Ok}$BH50Tp2LZ}Ec{K1};jsC~n|YScJ47lfJiI{nTfWpfJR z0faIO^tDZFBdMc#v#vAqV%N>(R)ZBD!T1fGsT%wEL((li(0nn-?qE|IE;siT>CNmFf{VAl1 zmH$JYdk?cxbn1s)9JCc#EHsodZj6=Sj%n-goj?Z0!#M8p{wzkdoGl|TLnV>!DVdei z-g^(1ex!`Lc5N$~I8k*{e+$yX-8K$W-O+N|C}P-_&*LfbsuXF77at2&c}_%FrEKb+ zKdWjhv^aTuO13a3{EY>H!%T<8BoD6$Ysc)h$a?!~BjNapf;j7?eN42d^P1JGUZS$j$qLDIqjG~X)3yt#k-LNw1K%)!G>5T}qXw9o5Y;Ap!w-@)g$?3?4I@QZjjd>RkkJ->weU>1Wdbaha8PYs4OoGu z`MlYiyBs(=dIIbi@K3jyaPTf}$@@RI^!V%HA-MRP!9#`TWPzQy_^W|YM0V1O0X=YX zw}BsiGjWs20erwAIR8Sjc2mp-$b+Ll0raTyHBj_dV=>pd{Po>vRW;mY$8HD_qE*zE z*F3aZH;eF|!aU$SpsH|ic-#hY# z7)g!SOaB;q(zj#h^g4(ft>t_6t3Nx_PF8`iStXC-NG^un4u(YXg$bu_A_p4LO^LRL zpi1hNhEU_rW$>((4dHT&Fwa>34}%UBJN;$mYaYM*hRJ(oOk2jxfzoO;KbH7CB8jizniwdpHC_@AFcUBRhqGtS|fv14CQ)#%D z)mz{K6+%to9A8zoVAOB8i-ff~{=_w;prU!XXvY;Z&}g(CnA)eYJ>%w5;z3r9DQkPH zn>cFJ4hpK`1Olz!0+E@`5qnwYjQy0?)ha<6JrUE|v{H#XkW?UAR4E)RD~+21dARbR z_4)rT+<&Rp|2e|VHpKE0^&JsTes_2Nzmi1s|2-q@{YN8x=Q9<=<4!_yJhi7W(N<5q znuwo_ivm|C6H+IsU>LOomrFRnYvq&U9SM$N{PNZXkjuG_a5z?%`@ISIR7Uf9cs{I3 zdYH!?bhSTjo-=`@Dxa(e9$IV8Lo90nEhIWi)`N@k^VZg%2hnI2y6X|y3Rs{}&6v@F zpO{3$80Q8eRC(2b1uR}ap;QootIrTq!o9#w!uQD`f?EhHp4L9j5Sc?YJ3c+*C^Lx-=?=u}O&OXnkZd zTHLf54bPwUFI zKG3pH^!6D4AF(9nOY^AH37G`<&37z${#PuiM?Y5oV(EIGh)|}FI=Q;9Is4w&DJ?rL z8Jcg2{glH91VWpWAdr%v1>p$=Uyr0qNF|k@-tecE6pGs^?Ul{9P>s=1LnJth&~ZvK ze5<-VX4(l;OA!5}1>jA@Jn!qbSv~!9Nd4&uyg$fW53}qa1(3rGIB*4K$-Q&p$!C#r z>jRb=uEu}J+4=GH_GcG=+`K z7U`h^6r6alhOE!`94lXXFq6c9Y@zzfiM( z6ASw5mHt0R&Q1;_W;wp)tezMEfc$@eQFadI-y?J#l#HF6%xz2^zb6U)?KcChfw|44 z#`JgL17q5Q%C_cbG8coq>Pn&_134Cwbv_d1rE+W7_(m|h!vuzBLFe|BBM=}meIU*f z@!4+`y9>H*z9;ne@vuDd>zD28=Wqk9&&-xHcwu1a4(Q4Wu~3 zpCtQ2&rOEjOx4QUgMdntvnc}I`4+|iYhBlb0Bx#jEZ`r%3-2%hv^LGkp0XIem;mZm zr9<@THc1m|3<0Diy@Xf2dIFMO0XFrchw$n{y8uI)Iaf?7=Spw=k7#fWFei#v4BH@w zOTg8BN(S;!?K`y2Y=up8Db7GMyxNAhV9L17pd9Cy_td$02u@+!jt z_r@Xc)knb$R?3_DV{Iu!vFZ0&(S}2C3WPz+GoVZc!+*nS(<<3pi6dyz1X_SC_wr0T ztkA0St_e`CuabjlwG`qLBsY>EO5;@yI(m(4zr(M162jM-w0F=~%hX#2dvgs4u-W-9 zDONsk3^g`1;hbieWu9yZpaJCp8obqDW1K+`u-eI z_o+%!s5onx)X<2``aG)&Qfj6;SMlKtMWKqh&>()uzmKDiWDc_cFv}LFdMd1t$X5vD zReOr$$tkcEZ?8W62x*T_dC|4S%aoIUwh!U+Q2 zai?2nN_>eFFVYX}N`JE}+WCU@w9eKVINZpYD{QR#VigtN8tJ<+)e~ca=k2qoy zO7-%kACE_??wxTOa(VX@4^Sp!XypeIQf-=Zsbtv&I@6ye+RE(0@-p5&aKF zq`%7pwK@vv1c`(QwJq=Wr^?FB`g>~IwiPQd~P44GyyK81=7ER5o&nq7t=1jHs>;7ReR# zfBeL$dO7Vg>%vnQE$$vI?-~Un=Iq4;bBGwK2^1nk()QI4&%;|RkOBx}*HlWanG2@w z(#Sg+-jHdw`Fzf5bj;+JkVF&7Kz9V|zmJ<}${5 z4zYi%Tt7j^d5UKeoAdFn_(}dauaZ~Uf!8pica3|#pKNqyae}|KDJ3mLXyjclxqpku zikTW!6oL<)=ZSS11xi}XZ_Muqt^#}gRbzAH0u*3|gX^5zGnnXhVTcyzHOKdyLuN;z zfTknp#xVX`NMEz-zn3sovTohRGr$96i;z*`i>hwq8 zp=GtPKe>00=RmT8%AwbZ+wYx)&TN|;%jMX*UvV;@{f z_+r8!9MJ@vQWo@m(79~}e%Mo>kRzs6`jV2jL<~MPb=&Sgv05f=*53aUi^c!@#@ZZW`~ z`h@5m-@^tWgSSA=y^04vTr{sh@?p065V$QE2*QNwORCxVY1tOq#Vhc$nXetAJqt%3 z*nvh7W>X}7%t5p!mMS4md=Dx+FIy{lu)Z-dsjFY)&bkb*Uq`vc<7%j6a)UYI$eZRP zM1h^!11-9l2nt%zU(y=8{Ye+G@K{uz zbof5BcRf?MxYhC)rXkUcg(Gw9uhjBn6}eb9atIy7_kn<503=pqz9D$`@k*crkWkg` zwOmaPpnn3iLwmJvU-^4IzBM7y3t6|s=T~m&xI)XDP#)J|TYqr!p2IZvsFOO9CJxLl z0ZlHQ=i})thKL^UkIiA%DKIxS@kSaZ$kZB`$SsnaSIM^-B43zmu!rZhWSI@_czbOpZ+#lBxL%buX`O1hn``Q2;Rgv^PI z-ElG$G@krOChe`IwqQ9OTF^!i!_yEQ=p)Y=+*@|7bdX-=|xonG}s~5|mmW(6?6Xx_IK7S{mWaoX;+nxAW_XN@&pOTD)^h zVc$0{>BhbJFz%@y&5=$vVtJ)L@MW7ZXLFAWlswgGXb_x}4eujT4S(NO zwL0v~5BEo?PE4?+s%HK(Cb>nSrbXE~`m5z9#|ZcX;A61xtZF9t@f<|zL{a#dj)`;V z#n1r&sbnjX2Ncu0ZgSp-Zz)RQ$8PR6qZ6M*NWUk{CES}tx3O8eyav{%@8F1+RjQ8# z2cxr#*2>fASUSQ*FXiCX(K3|U^$s7kRtl27ZF;Z=3s6}@PkC9b6`>tEOn6=bB5-NG z0o-!2DgTY5#fRj6@LJOl9RqSX4j-iRTHcipuycCRa7NjPP>HX(yd%&6$%4NzgiD!< zlI(7aNn+>?ktCS!=O;Ks5(5F6ynf+Bo>FZaDT@fC7%-Z-$q=Siyt%J?SYXS7xID0(7%UOFvS$7P8dRaEQeg2Kk@} z*5nh5qoVnq`rAGjrX9~vI&(OcZQdmFsnt!*1Y_vPamIA{DO(N+Brc2P4JC25T% zhy8FbyqI6fNUr+DAnS*>f$MNdq424E`|2LzR61B{wcNEbCFdM78cRVbvSb|=l&RFz zF67-(%fLt6&B}8?(O~O!WQdOS&=WK7nH|b5@<*AYEFrxGza3+JqOn`pd8BYyj}wjA z7N%1Hju$R0kdG%A1C|C7v(n|Tq{6h?IFIe#fc>t&l#iIwT`=`3Y#7(gII+}4EN5q) zm?tyJwm==`Y_X5I27uC(hAhX^PRt7S1%HB`B>aGSbMgs>ajWJ~p+DsU0Bk7R+tsV# zF>krCww>mrGjsB`JthhKf*$uJulXvW>wUSp7W8`VCqRC~DW@~_p+jX-mX!M=F9Qe> zvcwX%@)f}G_D?60f6o&BM#25(Tf$D$)z!(jB@kl#YfJcxC|OjucHCn{_}tR1-G>j# zAiafVC3qN-`R=YYS(CzY_@K4I+(;l%LjvLaxYZtIB?h6TbDdxE>ZxmJUlVKhs91c5 zAJ6RdmrR1>HG37)fhLa;iN8I~eSE#Tt62srgp`9o&Vx#IK$QYWYCTKv zW14R}X|D=5WhOflM@igQr!6rK)P2R%qm!DcrhT3u(v0=yeS@x3Z%wl z?*h&|aN1$FVUZ7OEsVEr`FS=EPFO|``aMO0X_)0?|@JP8?h7}L8DqWb@2}zh)JIK2mQvMn}41*OEDw&fkXDUaM z1WY}NxaX+(OUHemNx+aYwzJW1mOD<|0k#Hc!)r*?QjyS4BC=#cr1}CF(Yj!!`s%+u z@sE~{B&iQUp5$`!KtM%R4XP${n+TJ3)V%J59!}4JTP|yk85)-Rs21lS}{TWfc z>F)Bc60{!jOV-OB7(xZFe<<01?RlaP_iIPQU=HJv*{|N?rzDLIE8Zm+wJ44|A7K*a zopXp|6P>=uT7wowDTfCyhi4t5Qn|Own}>%kkM-V~V_CQ`v4|3H&Wh5d2G4@=Q>GLR8I|mMNvtsnFW6Wq& z@LssTtYwj$0S?*zJl~}PT?!m;mXADl^F#RJ3C0f|83`PWS^w&8xjA8q7b#*Y+Ym#1 z_3|v-Axow2>jprFREd?GIGqKIgui$mKC9emW8lB#54$CJV_0ym4%)z=r=C|vYC>fR z2Y*-L0W-J*caSs0mxU;)T8Dhb%Ss&!2?M zPG29fGR&e12mmIzh4~aF3=}MxK!Cz;9I?`8B~X|QD_Ma=P6IAyFt?;1CuWFK_dzl_ z3AA&Lsn~{T!;7-5+O&3hO1TZB6&ML5>`Y99<&%A!c0L(Tfqh864%aLu1yv4R%BfRcx^^$eALVlr7VR^n%wH(%Q zGb$6Y3>#2WW>{YKyiI9{nr(gX6Iw%Mz_heVlg+KAM&P>2Hk@^Tz1jXK_@;97zVV@@ zCgkdj6OIuSgfxwpTKo^_sVSqJQuqiH53o0#RcVIIk$TWWEmQ9nt0WbAq9ttD zrPwP%tfFd^#N8vVba$Tb3 z(n?c2n$38)dN*y_0&@J8`W-nWU>UvOVgE9JZ<3=7X;opa3lm@KIO&tF$S|Zi6KyVE zU%}Xf7VW#HpsC4VT-+#G@6w*KBzR_|zwH&}Z5SudwaY#uBYUyT&Y(g9&CM`qJ=v9q z96`xmSZhVjaWD~1rSe9`vWzwx^Kg)4U6l^~W5AXa9nFYCoDcN zI3FY4rowMh$}QI{8g1SEiB4vwlZvIAu^kicQ=)%d5YU5)S8`OEp5&%*R`N2g?bjeD z+@mVC_w~;TJD^au^vTYzoEcTg)$?u&=VXT6r4-< ziW@l+BnMB-5Hn)BGCfaYb26tEphW#lLP6CQus1oIezrtN>g0}Sx3+%oO+fC-%N;n2 z|Co6fs9S$}h4r@gr^`1w6@uWqFmtP*mJT#{)o@w3q^)buPo~`*)Y=2u9mmNY=?f59 z;YbvBDCi7_4u^i67G64aFv^Lcn7+@~xn4K$D39}8a4_15M23o_nH4mT2(4;hlpetv^yHgRwM zfbV@d?Q52Mj%Gv6W+>(hw9jk}Fu4njOixj_FEpXLaHs5B#L6R2b>SSHgSPan49BU4 zaqYHhnSl=~qnvUx&h%b&Ge%Tp^|02Gvc4b%x9D_Oh*=%3U}LU#K3jQ-C&hPSV3nQ> zs<$^~W2Mh`Do~T$4w$$ac4~d6n>F6Ld~SmdyfSg`_J#j_sl#?oi;!+Ao%Qh*O@G?_ zQ@=Zfv6Ld3vKd30Jv?LcXXA>dPfFjD4}NPG0|{%l+}Cjcrq%DF!Y%<;|m>}$J{Tc*gO?E5I1bm0%+DdF8F@4+644O~qWHV~uS#DVee zjXD;65St!l48;ADClRI_U+|J*>F$6!x(vY;Ga1ocAwUUKhT80Xd~1|Sub(}3I2=2_ zbF2}annL_BV8$nbCGa`mHLiX!2HCy5K*H#e_u~Z1uNZjsyx1ipA_*&|up7chA~v}j z>QS4)$ikp*lNG?#-XI3TBcLuDcFoX5h;A4u?duChk>3y~g&o6PS^5u4BR@Emq_INS zj9z0x_^X|&ygfl$@nJDgjl?U|)jL7~bl->QR!E-O3*e2>;o9*`+^B6Cu7#-R+aP4b zJ}#xRQROG5y}f1fKIRo4-z?+#0(H}%8TzQe@>h%K%NVWDOs5QNo)~-GIHaZ8WAa`# z?J^R{QD{=s*k2xY(cl>sLkzbTW^;R6#Pj^7otYn97)NnP^@9T8=!$;H8@ljoKMj5r zvIrsKEw{D1au2}14>PSK%)IFmMJ(z%ief#Cb{y3FAHnZr-iM zE1}l59|Ih{Pdp#&36S-DL*VPyuDp88&=}K~Ofpeycq*f#^yC*ICnAks+M2v+*jNF! z4h@A!@o#!MQoiow_&jx9THppULTi)IEUabFn7?;oFQS;GGZF=@8dNMC*eRFn5RcIKpm1a5-idpI4z3nzsSDXqbFD$DoO( zb3gKVZdF3wNW{RSwCe0qxN$f5APE?uS!e+XB4J*xd>0<%KQW%R(eob2qd_#QA=^T+ zrW=Yom&OstR6_e)GoWX}ZqBl@VnHC)?mL$`NZI&h#)U6N!!B4aML*2*M-(R{TYFF& zzX7P+=d$FCNFWpicH39O6=h3OzNmpCpAnSHEC~fxW^^jSTsI@rNx+~uh}$^xc zt=yZrxt!>anBx_S018B4Dso}{(*V9M0WDb_v{r^C)A7P-W^0ZYLEbIE;mLF5oc`b% zTiuj>9kFSgN^fV0(ZViNQQ-bt>2}p3)cMj#M^MW~vKR2PVf1v62_h&EA~cOfXnH`A zjagWfAq56szF}i9r;koMKE&GN+kC^hIIn~P83Iz07BS-m(Mp=WazKbcehDYTRS=U9 z>h7Cf#MKt1^cGMl{Qjez-!dR)Kxm-S%{9SODLX9Xrxf3?c_Wf?z#Eu+xq!Sa3jbq( z=HU>9}2&p&_MsI*ut&KzjwZdC%R1Ox!R}o$mKn5W6mjM|29!AM`gBtL= zltO1CKvRaCl}0fxkhhfl3vUQ$17>RX_c8V|H^#%8?;2vFufznEUU=pF0F+X7WFhWm zgYViYZtmq+@&{tdZsisrd?Y(!v-=2Jc0$CsjqRi6Dn7zo7oIc&%0;UFfp0zOQ7S%c{*OJpjADRGBLy*AK= z;6~*nL&|_A&gf`p_25^7?<%gx=6FVekF9w<(UOjAIYWc1-PZY|9&>NL)TRHZ@B|=W z1Zveu=Te^+4|Zldz_FZ-L-x4GX|indh^5;RO6Z#iJotOWSm^LpQ}C6TBPgjiV=t06 zE3pJZ(~Yniw9fTJY~SLt(IiSQ22m4N!;V9)@0isTpepTr;k0hq2}<0OPzOnG6Mz) z;`}Vhga(imF<_FibM2A6B{_cHAB$E({MEe1iuV;wX+IgAvnH z?U^Z8G6Nd7(d`x_AX zpYMKC`M*cyzuk}Ud#m8Ta=*W5t^aZns)4U7av)bMpr-oG(}dg(Z@m|`0sl)p)&LlY z9AJ~t=S1uQ`kh4k*TP_v*QhaPxXHi|7j#cF@CeYYOj=4XrZOiAw>c;s=b!xUly&jw z66Y})Lay}kJA?e1y#VY!#)Ha2 zd(~84(g+=yH5vFWT;6>|B(GTB?nvGg6=Yfoz;HH=g*yjANA4L5ZACaMXH>0)6^%(H!M@d0vR1 zL8P(l{caIVI{nc`e{2BQ+XhbsW8#Nb2@c<;xjJ_x8yumf@)W%S$ddO0q3>BUz}qK) zhv&T>-pOwP3#RXc5=b7KH-HZl^Df)BjS(-IK}AVf10PmwX2JcQHQ_T~gr*&~GOeYR z9J#o4I>dJF-PBNN*g=%pN;m=(jpA6;z@34$U^%fU*yLx<#3&`2H9e<&794qmHyiz# z>DE~_$kxa!gA6y@F%GiOKQ`vJvE_x_7VJM_w217 zLNa?6bWwGLeO7cAa@!xm+jFg)J$m}>gYNzeW&)(tW8@@~@cMbZ{ujy+D?|EG0el_G)vSC5@A*E_~zk|V!X2t`IchqVE!m*k)7=v&$u}(w4?6K{W)A zziyle=_QIq7m3;(DS`Dtb)mgsneyMm>YuZaOz|k#%rMrtpj?cmxL~5*0(y7Eq_PBF ztUNrZ%?-d?A|2?ZBpl=S0JL_}DKTcFF7ijut0_jA&3?m7>U9JLXJ=*EEv zvCl}X#7WE`jw*OQF7-E$bPi@TX%@PQ9lEJ?tw&&7#8I8^mMq5t=oJw7AsDm_o}Yy< z_XEwAkcgMzQ?a!J+hBw$p z(ZUPI4&Y|!ad;qZ@P5!BJ|Kvnw!3a*c3g70{ZH3g+(6j5fp9Obr z^`AAa$Mlty!tNo6?f6!}GEZqeHZJ-lpK92o7hC3A`aCQjJ{Ngs=-bzWzH4Ul3>F|p z7OS^W!Xo!Fa_Q59t_v$8mJ;MwR7eKgi|L>F*QVY@?%0U;cl&+;i=r$@+b!)FYvee| zziZj1ZWz5wCjn4M+SEjt7B7AV9rL6m1#!({a2lN!7_m3AhhmqC+&cjB`2wjY2sQ)qFkXdjs!u|O+YrI z;#`Tst4dT-+*xHZgvFY~X6sTt(T!bU|FYw0=5~_WGCywcE+G>pB9kT2B<$rn;ClB@K=b=@JQBPGddJiD;88RgklDC8jRxbH2@cJ)qc|yP{)Q-z z4nmd}$kv9fwySG#vP#fRySYk#-Dq zzhQkzD#!#8fB|oU=XHSzqk)c}$T9v*3{?prAHylhu9x&AOVx0hbbMb}jLKVx9=NM4 zt172DEb`UXq0oCBf#yLdC?J}?&j=fusLtBx>E7pEXP8y7cEjVe_-%W#7I}!(X9H^q zV-iJY4f339QQP1dzuP8X=xuQI3)kKu(%iGAmnBaiTxff0k1|%Hhdw-}9|D%K0YiAU zd_m*oXRf6AeexL6h28;0I_7j2*|_W4LQ!RyH_ZX)en+${TkYz}eNkM=wh9eEZ9-7x|jKO*&ZBmC)lm%NWMz_b!%gj6S-s%q? z1WP2tLP&sg`~kB}H_ga6Fs&&*Tn4>RtXp621VyC&n6v~XtnbGy*@9R;&*#iGa$EN% zb@$?VipsjJ3RkajSgJkLtZCbaY9pz@17+DF7MMh8!SewYsRXCl0Tzk<4gBKUVHCtv z%=D8rNVuX`)&1LK^hRTnl{Lq2P{ziq0dq^#%O!k`U}uU4O_YWvRZt+;=#OEXAD&{$ z3VK6Xjt?!4$GfV@2IpFg1dF@?UD~Zp)%Df&TaIfk!7wf*O1_S zrg=;r;f(w3j}2dRi}S>|1@`$fK8Y}3&nQ?0_9$#nGK3YC0bf;lXWQt)JdH(Xlw35@ zOl^#9TNe#n2J5ez@h*RG%C9*_kF;R}BFWXpPPlC!ug{sKf+QNx|>?1uuX-9&P^zqHa+nHEC_?k^h-^xvT5My<|%SPMqt>V*g z|60ZWV)_=<#$CS4D4WkzM|MQ*je>Ec#)DCrh8)TDB|q_gYCD7aXp)*%v1O@JQlO(D2FdZ<9E(;K$N_sibpEd!F4QnqBma`DLxZ3=)_ z5Y^!eO0Zs3q+NFKz8@v=Hs%4qBtIp>R7+e*`awX`twMCd09<4X(%E1)z7FVWce_u+q%do>*!IPZT;_$I&H z%2?C_RQx6O7Ul+&?i=bxhDCsJe?n%Jq|z(d5gbO`Az7*mh-D@b&VxOMC)E_t0x+q$ zOMJlz|Eytb5#4()(H5Y&;U=mQ>Ps&@V`SS%H(3N;g{2l7?PsI1A+2yd=X%v-b(gy5 zIZ96I)Et7cpKur`dHJ+uh&_$g{sC-X8&@+Lb%jPq&X9b~9h&`xZU0Hj@qLDe15h$j z$Bh((?}U{H0St90|2sR+bRBCuPqQ2F^1+-mg3rdP(Nqm?A^}tI>wP#^mtHabVO2S# z_cT;V;UQC4fVpeXTzJR_M;WBKwy3E?OF?11UxUUg++%B>r_F-MlD} z1fAU7Cl4&K`xagUmiT?eGd#)7Db0y?w${V@b2EUerVn}c(PVaT#j?P@`?#c$MdPOZ zHo%GtoFE07Cz7@N%(`b1LUHwB`&e8| zbqxH__VR={z26JU5n#N4 z)I0XUA>Q!{8rWh~bSn~9*xBp_%jV{pcbhn{M?L)-_G}hX|IYda5``TP* z+ov6qXR4^XeevK@1>2G(W!sdW8$`xhOBtO`lrmUyjl|##!QjwfWKwLTzLAScYx2Fp zG7dGAxBG$=(V$qRWMua6?X=KbdfGXUh{v!=@|s&^+uCf{+dPbba+GOiWnEHJ} z*GCn_wcu6sthHx{$MgQb)iU)rfcZaP+r?_0+9i?Ivn<=aw zs~3rf)#VF6*)ObU6f_#NZVC|#*8)L;0ic%t4{e8z^hf?3Mlg3utAz8!Yp=YH2d`MT;;TmXQhrwi{-#$R?}jep7wE5d1wrU9BaL* z7cexDo_*%VnRFX^sHJv`t}Ru@WO7puL#KK|6lK~4y>yu#S5IfN_}G(rfF5L-hGkMe z;UzkgiX1s*j(U+}q-0ttizKJbRN#?<-ed=^Gov=}TpX<2+t@mcpi+s9h;!)grWOsK zbDO#OkysxIj|E$eTQb-oIlAeVQ$of?*j55^odLI0WcxbTkt}t`=BTdEDAmzG)xx&8 zmh7Uoq${gpQr#mva}rGUYcm)12twAgxS9zVYSfcf{teGW)l%M$rD zE&H^%PE2N$80r2l(+Jl!pVRri7k1bANI^JW0f3_Myi*zndj3@5IPddK?=B`#7>R+Q#mIQW{26 z0J^}Sa5v7I{xvS@2mM9Xk}^!6&ATq+t^RTIb|%a-2VUU#wL^sk+404Fz}O z2Ez0aL04LU2%;b=*nfI|n0W)YZ=RlvMG%sF`K@`gilH3$J{wRp3Ke%U~DT zKRly%a6;={ws_fUO%DNLO7lYQNXWSkgv%9ByvB$?^um}){->Hxy#vm%*m1mhd!bYC zVVaeA$oKK$BcYhlu@?S|>`_)4CX|QiXWPBRgAu6aQ_9R-dE>ism8tpKySKXuceh`F z=~sW2awC}2fo&#IywgV*VyxXv}V{#~{d(pLGx5Y|8$glA_L^~3a4PO7m z8qm&Bv!urtk!s8OgarCXsM!5fu`|~l^xz!{z&Xf$y}iAKvzpPx1LE}1tijIg2JdoP z+Tr5bIpn<&3ksrJ_FERUPmUAVbuVgBKtzd{5WvEVr%Yg7Plw>gB*1>6IK%`gg-N9c)_8Dvd1+?2Cp5}3!x%U9 zn*V;fw62Qqr&VDbXKnQhqYqIz5IPJWcQ+s9SE1LsJP9Cv1Q-t2G8^GqSeXqfr$yi z6oPjZXVeUJ6A+PN_>d?ARYFOY~8tO812)7}tfzWeVkA3qP9v5Nu zyQ=dO{Qpj+{cmLTf4-y4!G6yAeme>s8UVoe=dbP8f9<*apDfg0=;h(}91JWrl&)(f zvzC%e%zfh&ZE114A?LjYrpf7*Q4zYqDu6{ziMR#~hsBT+^Ly5yxn}auC5W!OQ=ia<6)oCCa!q+ zUU&aNMj=2*apm?eEpYIJ5@yJ0NL+rb{Sec)%7GhQ`YlqbDVfs}far z3+wzaNpo80ow1gjNFi6)XP91vPEsRrT9kxA7AVbfu>MJQL*0!M^ws#*sXxItx=NK# z_sE4@v^}wf*%zTtWrHXo#cI`n<9WJu}TO<6BE9#g9+OaS))NVj0jcVo|)fO~3-l^1}_6lbLfIMkfp+W@kA$X5({Q z>l@mQPC>=_4~6ODt!^G_FD5ug6ADaBH%9j=o-uj$sm~vOc#vwWy^6YQW%*By@7G&X z06-p+R-ecHcxcMbW4ZH>LM(j^kL+TtUBqIZj!SN7TmWhiBd|6gS@Bk{=?&p0SZw_X zNd^yHP24voSrsHjo3vWQU!hiY+xXnL{L`h`eoy)}wh1EXRr$B-@zg@vW?McaDsn5( zcgbOX{jvT%>&z5B@th6j5_k9~)CrhS`|z-Nn!K|6ukWVV<{2w#N8%kA zv2vsZ))nw^^tTwuGUV2Mr8>p5!J*iqV5nY@=EMB4o9NO$@4FTq5afO7HxH2QZUhneSTpqKH zEy%BU*g5O*Eu2@&V-;=w$1K$ZJ6EfMM|?>2G9m(y~hKT`xm>k>|d8T z&F{B>vINYUSTcLz6cqE2HDnOEMh>JrFRd81SW-Gfyx8ZD3z= zsXM^AyUbP4Bn(L)I1VKDV}UN%=;xzL{x>Z;WbK>h1>2>|qjS7#Su^KOIZlS#Y1?Pc zoSn=P89HQAvBjT!%jYXGs4Tkcm+DzL(c1S9Q>*DBor}%}JiZAb8jkgUG+DQXo5v|S zDCFf$7wjlE0a_bD3=g6xSJEiGaqOWsnUmCZ;#2?{k|2pN$h2F~3~yaq9d^7>aayM^XC+S+yuSv+Feqgooo- zduoiXYNdY}_M3ltSEz7O`xu@-bl@0UC&yfVuW~tMLVfYB-D>Qvsz6NkUFL;n7ZG0Y zwATeOD=?zdfZfi8UZ`x`6N#ZSOJA08_(J(yh%hzS?vsljBE|%9c=9!>72}*tqpAS&8=uH?2Jt> zRW0q1RZw`_J=rL%ClSbLP{8}z8G8z71gU^X&DV+&Sqo^Y?Smx}WRTNI^Xi>(y0PaTW`kB&j$78rHLZ$L6pAFoPP+DwwcR z>(Viw6{Z2rh2iA(HK@SJcCTx2=>UFs=|_ld(SmwC#4vZ+CRH+5Ud#Mp z!xDaWq?@letJ1sj0AJ9LbLr4x;2I)s1M{pg(`)PdC0e1(sFlkE>qZ67pSfLSnpxv6 zEMVvyd6^%yqy8)bVWD#Fpg#-K;lXWw4@jN|xn;6x;YLlXh)=RCsb{iQ7fMF;hm~ic zx(u|2;vok_Td~UFz-w7`Z^bAsM0uq z->DPT;j{;B#&c^+QG^mYjb{(LqtJ`Qm|5mb!kF@?aZK)}Y>tgp%eK2pW)Gd-Vxtmz zSV|p<;a*)6|5*ZAL#{>d ziS&GfG=qdyqwHe+Vs^sKylV6F6%j9D_nJydu03z*;kUf0)yb&LGU2G6nSvh7YEe)}B^7~$P;oRA# z&C;0OcdjXNm(3PN_|>MY$=-0=)Gpm-yE8>cPIc?M2RHn@%adR0*`yaXY&Tc0YPjCh zJ;-LX@%*NfeHyb5E!QfqZv@Xt-Y4e_X^H$uj)47|&HnRkNlw;}QBxyb<)AHOY`)jnM5(KT?% zqicBkj|n=bt4!J0(`TvQtslImBabF59Gy0^LY z->uF!>actEr{E>qyc;2%QU2nB#_u@b?-5o*O+2_x2`a1ecJt#wlV&#f%T#`j!5h?#L3hN3*B-3qCBri{L;3C_sh<~ zmHTDq7df`H)ZHk0o%4gKGk^ZPh<+WF1nz_r%3nm(xTI2g(pB%W^ zTpBjE!E|6eWT0#!`@m>-$K?;HMx4fk`1ZH1 zx{&+#;dOjJHwSs{4gI3SI&TkU|8$ux@R!G2Vmn^p2l{79J2Q<-@0!hBU32u@yg9id zuTvcB?ll>COG@kn#*^F~0e)^xd+v1&-g@cCS$1ft@9^p4GHc_cq#VI6eS@T=Nr$x` z6hmED)ATUVw+WkxZIg5bY}N4n<^>DGHiB^Y|9FgSJW~!l*z+ zZ6_Eoq;21ifj4)6nhu~q)YO8r=7BBBoJfqMha0Boed?5ghRxt@VCPc{&T;|OS*9{f zaO7SgYQLvyQ>6o-c?5Xc^2JnR48vK9VU1JG1riCWH9yDtP8is6y*5S!Lj}NDGeFsD zi6k17fDuN4BNs%6Mv75{IfuqG0>S>xW?`7AsySHWEIHVsASjO=<{)DckhMW?Er2Df z#bOPJsY-`PxhNOW3bUiXgXRZ8;OL>etfg!o8!Ft#*!=kHh?w~r7}jG92SW-TV=49? zy-OG&RQ@GOBf#hK$;8pLTyXG3;Fta>dI&yh1VQj!xNu9fG+Y>kI)Xe)x!oI}P-mqq z8mb^xkVNgqBfViVJas53>I}}E8kr(Wj247(%n|LdLEzLx$svd^8J<9o6#d5*L8gcjV_9Gv zv#7Z}0;eWQ4v>M#@C;R?sD%T9Oc5oz9m6gkK{;CDD+uy1;lfKK&6Y=_5$6J#&6tI}gJ}qg5OW5D%YKkT>q2NWLI|0) zn1%SEGz3M6Ia9&o&PkzdyJ<8+2$`Ljg~B3f2#OGMMuJB_l0tk5jYbF|vk target/BeakGraph-.jar +``` + +Run the CLI either from the shaded jar or via your own classpath: + +```bash +java -jar target/BeakGraph-.jar -help +``` + +## 3. Quick start + +```bash +# Convert every RDF file under ./data to one .h5 per file under ./out +java -jar BeakGraph.jar -src data/ -dest out/ + +# Convert one file +java -jar BeakGraph.jar -src data/example.ttl.gz -dest out/example.h5 + +# Merge an entire tree into ONE store +java -jar BeakGraph.jar -src data/ -dest all.h5 -merge + +# Serve a store as a SPARQL endpoint on port 8888 +java -jar BeakGraph.jar -endpoint out/example.h5 -port 8888 +``` + +## 4. Command-line reference + +| Option | Default | Description | +|---|---|---| +| `-src ` | — | Source file **or** directory tree. Directories are walked recursively; every supported RDF file (see §5) is converted. | +| `-dest ` | — | Destination file or directory. Required with `-src`. In per-file mode the source tree structure is mirrored with `.h5` extensions. | +| `-method <0-4>` | `0` | Conversion engine — see §6. | +| `-cores ` | `4` | Threads used **inside** one conversion by `-method 2`, `3`, and `4`. | +| `-threads ` | `1` | Number of conversions run **at once** (per-file mode). Each conversion gets its own `-cores` budget — total CPU ≈ `threads × cores`. | +| `-merge` | off | Merge **all** sources under `-src` into ONE store at `-dest` (if `-dest` is an existing directory, writes `/merged.h5`). Blank nodes stay distinct per source document. Works with every `-method`. | +| `-spatial` | off | Build the Hilbert-curve spatial index for `geo:wktLiteral` geometry (adds the `urn:x-beakgraph:Spatial` graph). | +| `-features` | off | Also derive 2-D shape features (area, axes, …) for each geometry. Implies work under `-spatial`. | +| `-workdir

` | dest dir | Spill workspace for `-method 1` and `-method 4`. Put this on your fastest disk. | +| `-huge` | off | Legacy shorthand for `-method 1`. An explicit `-method` takes precedence. | +| `-status` | off | Progress bar (per-file mode) and end-of-run counters. | +| `-endpoint ` | — | Serve the store as a SPARQL endpoint instead of converting. | +| `-port ` | `8888` | HTTP port for `-endpoint`. | +| `-version` / `-v` | — | Print version and exit. | +| `-help` | — | Usage text. | + +**Exit codes:** `0` success · `1` bad arguments / missing paths · `2` at least one conversion failed +(each failure is also logged with its cause; per-file mode continues past failures). + +Existing non-empty destination `.h5` files are **skipped** in per-file mode; `-merge` always rebuilds +its destination. All writers build into a sibling `*.tmp` file and publish with an atomic rename — +a failed or interrupted build never corrupts a previous good store. + +## 5. Supported source formats + +Turtle `.ttl`, N-Triples `.nt`, N-Quads `.nq`, TriG `.trig`, RDF/XML `.rdf`, JSON-LD `.jsonld` — +each also as gzip (`.ttl.gz`, …) or zip (`.ttl.zip`, …; the first non-directory zip entry is read). +Named graphs require a quad-capable syntax (TriG / N-Quads). Files with other extensions are +counted and skipped. + +## 6. Choosing a conversion method + +| `-method` | Engine | Memory | Parallelism | Use when | +|---|---|---|---|---| +| `0` | Sequential in-memory | Whole input on heap | none | Small files, maximum simplicity. | +| `1` | Disk-based (`huge`) | **Bounded** by spill batches | none | Input too big for RAM; native HDF5 required. | +| `2` | Parallel in-memory | Whole input on heap | `-cores` | Medium files, faster than 0. | +| `3` | **Ultra** in-memory | Whole input on heap | `-cores` | Fastest option **for data that fits in RAM**: parallel parse, O(1) id maps, radix-sorted packed keys, parallel index emission. | +| `4` | **hugeUltra** disk-based | **Bounded** by spill batches | `-cores` | Multi-billion-quad builds: the `-method 1` pipeline on parallel machinery — background radix-sorted spills, packed primitive keys, grouped term runs, concurrent stages. Native HDF5 required. | + +Rules of thumb: fits comfortably in heap → `-method 3`. Doesn't fit → `-method 4`. +Methods 0/1/2 remain for compatibility, minimal-dependency, and low-memory-machine cases. + +For per-file batch conversion of MANY small files, prefer `-threads N` (parallel conversions) +over large `-cores`; for one big file, all the parallelism comes from `-cores`. + +## 7. Very large builds (`-method 4`, 10⁹–10¹¹ quads) + +```bash +java -Xmx32g -jar BeakGraph.jar \ + -src shards/ -dest giant.h5 -merge \ + -method 4 -cores 32 -workdir /nvme/scratch -status +``` + +* **Workspace**: budget roughly 3–5× the uncompressed source on `-workdir`; use NVMe. + The workspace is a temp directory created per build and removed afterwards. +* **Heap feeds spill batches, not data**: RAM stays bounded regardless of quad count, but bigger + sort runs mean fewer merge levels and much less disk churn. Programmatic users can raise + `setIdSpillBatch` / `setTermSpillBatch` on `HugeUltraHDF5Writer.Builder` (defaults: 4M id + records / 512K term records per run; each id record costs 16 bytes × 2 buffers while sorting). +* **Shard your input**: `-merge` over many files is the natural way to feed 100B quads. +* Blank nodes are scoped per source document (labels are not preserved in the output format; + readers regenerate labels from dictionary ranks). + +## 8. Programmatic use + +Every engine is a `BeakGraphWriter` with the same builder shape: + +```java +new / *Writer*.Builder() + .setSource(new File("data.ttl.gz")) // or .setSources(List) for a merge + .setDestination(new File("data.h5")) + .setSpatial(false) + .build() + .write(); +``` + +Classes: `hdf5.writers.HDF5Writer` (0) · `huge.HugeHDF5Writer` (1) · +`hdf5.writers.parallel.ParallelHDF5Writer` (2) · `hdf5.writers.ultra.UltraHDF5Writer` (3) · +`hdf5.writers.hugeUltra.HugeUltraHDF5Writer` (4). + +Reading: + +```java +try (BeakGraph bg = new BeakGraph(new HDF5Reader(new File("data.h5")))) { + Dataset ds = bg.getDataset(); // query with Jena/ARQ as usual +} +``` + +## 9. Output guarantees + +* One HDF5 format, one reader stack, for every method (format version 3). +* Methods 0/2/3 produce **structurally identical** stores for the same single source + (same datasets, sizes, attributes); methods 1/4 produce **isomorphic** stores + (blank-node labels are rank-derived rather than relabelled). +* See `docs/BeakGraph-HDF5-Architecture.pptx` for the on-disk format design. diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index 222c5119..fd247e98 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -4,8 +4,11 @@ import com.beust.jcommander.ParameterException; import com.ebremer.beakgraph.Params; import com.ebremer.beakgraph.core.fuseki.SPARQLEndPoint; +import com.ebremer.beakgraph.core.BeakGraphWriter; import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; import com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.hugeUltra.HugeUltraHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.ultra.UltraHDF5Writer; import com.ebremer.beakgraph.huge.HugeHDF5Writer; import com.ebremer.beakgraph.utils.RdfSources; import java.io.File; @@ -229,39 +232,11 @@ public void merge() { if (dest.getParentFile() != null) { dest.getParentFile().mkdirs(); } - logger.info("Merging {} RDF sources into {}", inputs.size(), dest); + logger.info("Merging {} RDF sources into {} (method {})", inputs.size(), dest, effectiveMethod()); try { - if (params.huge) { - // Disk-based merge: all sources spill into one workspace/store; - // blank nodes stay distinct per document (see HugeBuildPipeline). - HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() - .setSources(inputs) - .setDestination(dest) - .setSpatial(params.spatial) - .setFeatures(params.features); - if (params.workdir != null) { - params.workdir.mkdirs(); - builder.setWorkDirectory(params.workdir.toPath()); - } - builder.build().write(); - } else if (params.parallel) { - ParallelHDF5Writer.Builder() - .setSources(inputs) - .setDestination(dest) - .setSpatial(params.spatial) - .setFeatures(params.features) - .setCores(params.cores) - .build() - .write(); - } else { - HDF5Writer.Builder() - .setSources(inputs) - .setDestination(dest) - .setSpatial(params.spatial) - .setFeatures(params.features) - .build() - .write(); - } + // All sources feed the ONE store being written; blank nodes stay + // distinct per document in every engine. + newWriter(null, inputs, dest).write(); } catch (Exception ex) { fc.incrementFailedConversionFileCount(); logger.error("Failed to merge {} sources into {}", inputs.size(), dest, ex); @@ -271,6 +246,92 @@ public void merge() { } } + /** + * The conversion engine for this run: {@code -method} (0 = in-memory, + * 1 = disk, 2 = parallel, 3 = ultra), with the legacy {@code -huge} flag + * acting as "-method 1" when no explicit -method was given. + */ + private int effectiveMethod() { + return (params.method == 0 && params.huge) ? 1 : params.method; + } + + /** + * Builds the writer selected by {@link #effectiveMethod()} for one + * source-or-sources -> destination conversion. Shared by the per-file + * processors and -merge so the two can never route differently. + */ + private BeakGraphWriter newWriter(File source, List sources, File dest) throws IOException { + switch (effectiveMethod()) { + case 1 -> { + // Disk-based build: same output format, but sorting/indexing + // spill to a workspace instead of the heap. Needs the native + // HDF5 backend on the classpath (the hdf5-backend-* profiles); + // with -threads N, N builds run concurrently, each with its + // own workspace. + HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + case 2 -> { + // Multi-threaded in-memory build on a pool of -cores threads. + ParallelHDF5Writer.Builder builder = ParallelHDF5Writer.Builder() + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + case 3 -> { + // Ultra in-memory build: parallel parse, packed-key radix-sorted + // indexes, parallel emission - also capped at -cores threads. + UltraHDF5Writer.Builder builder = UltraHDF5Writer.Builder() + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + case 4 -> { + // hugeUltra: the disk-based pipeline (bounded RAM, any quad + // count) on parallel primitive sorting machinery - the engine + // for multi-billion-quad builds. Needs the native HDF5 backend. + HugeUltraHDF5Writer.Builder builder = HugeUltraHDF5Writer.Builder() + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + default -> { + HDF5Writer.Builder builder = HDF5Writer.Builder() + .setDestination(dest) + .setSpatial(params.spatial) + .setFeatures(params.features); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + return builder.build(); + } + } + } + public static Path mapToDestinationWithNewExtension(Path srcFile, Path srcDirectory, Path destDirectory, String newExt) { Path normalizedSrcFile = srcFile.normalize(); Path normalizedSrcDir = srcDirectory.normalize(); @@ -309,44 +370,7 @@ public Model call() { return null; } dest.getParent().toFile().mkdirs(); - if (params.huge) { - // Disk-based build: same output format, but sorting/indexing - // spill to a workspace instead of the heap. Note the huge - // writer needs the native HDF5 backend on the classpath (the - // hdf5-backend-* profiles); with -threads N, N builds run - // concurrently, each with its own workspace. - HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() - .setSource(src.toFile()) - .setDestination(dest.toFile()) - .setSpatial(params.spatial) - .setFeatures(params.features); - if (params.workdir != null) { - params.workdir.mkdirs(); - builder.setWorkDirectory(params.workdir.toPath()); - } - builder.build().write(); - } else if (params.parallel) { - // Multi-threaded in-memory build: same output format, but each - // file's dictionaries, id lists, and indexes are built - // concurrently on a pool of -cores threads (default 4). With - // -threads N, N conversions run at once, each with its own pool. - ParallelHDF5Writer.Builder() - .setSource(src.toFile()) - .setDestination(dest.toFile()) - .setSpatial(params.spatial) - .setFeatures(params.features) - .setCores(params.cores) - .build() - .write(); - } else { - HDF5Writer.Builder() - .setSource(src.toFile()) - .setDestination(dest.toFile()) - .setSpatial(params.spatial) - .setFeatures(params.features) - .build() - .write(); - } + newWriter(src.toFile(), null, dest.toFile()).write(); } catch (Exception ex) { fc.incrementFailedConversionFileCount(); logger.error("Failed to convert {}", src, ex); diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java new file mode 100644 index 00000000..237759c3 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java @@ -0,0 +1,27 @@ +package com.ebremer.beakgraph.cmdline; + +import com.beust.jcommander.IParameterValidator; +import com.beust.jcommander.ParameterException; + +/** + * Validates {@code -method}: 0 = sequential in-memory, 1 = disk-based (huge), + * 2 = parallel in-memory, 3 = ultra in-memory, 4 = parallel disk-based + * (hugeUltra). + */ +public class MethodValidator implements IParameterValidator { + + @Override + public void validate(String name, String value) throws ParameterException { + boolean ok; + try { + int v = Integer.parseInt(value); + ok = v >= 0 && v <= 4; + } catch (NumberFormatException e) { + ok = false; + } + if (!ok) { + throw new ParameterException("Parameter " + name + " must be 0 (in-memory), 1 (disk), " + + "2 (parallel), 3 (ultra), or 4 (hugeUltra disk); found \"" + value + "\""); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index 4db8563d..42be415d 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -33,8 +33,8 @@ public class Parameters { public boolean features = false; @Parameter(names = {"-huge"}, converter = BooleanConverter.class, - description = "Use the disk-based writer (com.ebremer.beakgraph.huge): sorts and " - + "indexes on disk instead of RAM, for sources too large for the heap") + description = "Shorthand for \"-method 1\": the disk-based writer " + + "(com.ebremer.beakgraph.huge). An explicit -method takes precedence") public boolean huge = false; @Parameter(names = "-workdir", @@ -48,19 +48,28 @@ public class Parameters { + "into ONE BeakGraph HDF5 file at -dest instead of one .h5 per source " + "(if -dest is an existing directory, writes /merged.h5; an " + "existing destination file is rebuilt). Blank nodes stay distinct per " - + "source document. Works with the default, -parallel, and -huge writers") + + "source document. Works with every -method") public boolean merge = false; - @Parameter(names = {"-parallel"}, converter = BooleanConverter.class, - description = "Use the multi-threaded in-memory writer " - + "(com.ebremer.beakgraph.hdf5.writers.parallel): builds each file's " - + "dictionaries, columnar id lists, and GSPO/GPOS indexes concurrently " - + "on -cores threads. Ignored when -huge is set") - public boolean parallel = false; + @Parameter(names = "-method", validateWith = MethodValidator.class, + description = "Conversion engine: 0 = sequential in-memory writer (default), " + + "1 = disk-based writer for sources too large for the heap " + + "(com.ebremer.beakgraph.huge; needs the native HDF5 backend, see -workdir), " + + "2 = multi-threaded in-memory writer " + + "(com.ebremer.beakgraph.hdf5.writers.parallel) on -cores threads, " + + "3 = ultra in-memory writer (com.ebremer.beakgraph.hdf5.writers.ultra): " + + "parallel parsing, radix-sorted packed-key indexes, and parallel index " + + "emission on -cores threads, " + + "4 = hugeUltra parallel DISK-based writer " + + "(com.ebremer.beakgraph.hdf5.writers.hugeUltra) for multi-billion-quad " + + "builds: bounded RAM like -method 1, but with radix-sorted bit-packed " + + "spill runs, background spilling, and concurrent pipeline stages on " + + "-cores threads (needs the native HDF5 backend; honors -workdir)") + public int method = 0; @Parameter(names = "-cores", validateWith = PositiveInteger.class, - description = "# of threads each -parallel conversion may use (with -threads N, " - + "N conversions run at once, each capped at -cores)") + description = "# of threads each -method 2 or -method 3 conversion may use (with " + + "-threads N, N conversions run at once, each capped at -cores)") public int cores = 4; @Parameter(names = {"-version","-v"}, converter = BooleanConverter.class) diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java index 9324adda..14eb25b1 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java @@ -89,7 +89,10 @@ public Model getModel() { } private static class Stats { - private long numtriples = 0; + // LongAdder rather than a plain long: every other structure here is + // already concurrent, and the ultra writer calls add(Quad) from several + // document-parsing threads at once. A bare ++ was the one lost-update. + private final java.util.concurrent.atomic.LongAdder numtriples = new java.util.concurrent.atomic.LongAdder(); private final ConcurrentHashMap predicateCounts = new ConcurrentHashMap<>(); private final ConcurrentHashMap> classInstances = new ConcurrentHashMap<>(); private final Set distinctSubjects = ConcurrentHashMap.newKeySet(); @@ -98,7 +101,7 @@ private static class Stats { public Stats() {} public void add(Quad quad) { - numtriples++; + numtriples.increment(); Node sNode = quad.getSubject(); Node pNode = quad.getPredicate(); Node oNode = quad.getObject(); @@ -114,7 +117,7 @@ public void add(Quad quad) { public void applyTo(Resource graphRes, Model m) { long entities = classInstances.values().stream().mapToLong(Set::size).sum(); graphRes.addProperty(RDF.type, VOID.Dataset) - .addLiteral(VOID.triples, numtriples) + .addLiteral(VOID.triples, numtriples.sum()) .addLiteral(VOID.classes, (long) classInstances.size()) .addLiteral(VOID.properties, (long) predicateCounts.size()) .addLiteral(VOID.distinctSubjects, (long) distinctSubjects.size()) diff --git a/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java b/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java index 518e8705..fa06d0bd 100644 --- a/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java +++ b/src/main/java/com/ebremer/beakgraph/core/lib/NodeComparator.java @@ -24,7 +24,18 @@ public class NodeComparator implements Comparator { public static final NodeComparator INSTANCE = new NodeComparator(); - private NodeComparator() {} + protected NodeComparator() {} + + /** + * Node-to-NodeValue conversion hook. {@code NodeValue.makeNode} funnels + * through Jena's ONE global bounded cache; under a multi-threaded sort of + * literal-heavy data that cache's eviction lock becomes the bottleneck + * (Caffeine "excessive wait times" warnings). Subclasses may override with + * a local cache - the ordering semantics must remain exactly makeNode's. + */ + protected NodeValue nodeValue(Node n) { + return NodeValue.makeNode(n); + } @Override public int compare(Node n1, Node n2) { @@ -59,8 +70,8 @@ public int compare(Node n1, Node n2) { // If they are both Literals, we must sort by actual Value (e.g. 2 < 10), not String ("10" < "2") if (n1.isLiteral() && n2.isLiteral()) { try { - NodeValue nv1 = NodeValue.makeNode(n1); - NodeValue nv2 = NodeValue.makeNode(n2); + NodeValue nv1 = nodeValue(n1); + NodeValue nv2 = nodeValue(n2); // Timezone-sensitive value spaces cannot go through compareAlways: // it answers value order for XSD-determinate pairs but silently falls diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java index 8962e022..fb8b8901 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/MultiTypeDictionaryWriter.java @@ -65,22 +65,29 @@ public class MultiTypeDictionaryWriter implements DictionaryWriter, Dictionary, protected MultiTypeDictionaryWriter(Builder builder) throws FileNotFoundException, IOException { this.name = builder.getName(); - logger.info("Building dictionary '{}' ({} nodes)", name, builder.getNodes().size()); + logger.info("Building dictionary '{}' ({} nodes)", name, builder.getNodeCount()); Stats stats = builder.getStats(); this.et = builder.getEnabledTypes(); // --- STEP 1: Strict Total Ordering --- // INFO bracketing: sorting tens of millions of nodes takes minutes with no - // other output - this is the writer's longest silent phase. - logger.info("Sorting {} nodes for dictionary '{}'...", builder.getNodes().size(), name); - long sortStart = System.nanoTime(); - sorted = NodeSorter.parallelSort(builder.getNodes()); - logger.info("Sorted dictionary '{}' in {} s", name, (System.nanoTime() - sortStart) / 1_000_000_000L); + // other output - this is the writer's longest silent phase. A caller that + // already holds the NodeComparator-sorted list (the ultra writer shares one + // sort between this dictionary and its node->id map) passes it via + // setSortedNodes and the sort is skipped entirely. + if (builder.getSortedNodes() != null) { + sorted = builder.getSortedNodes(); + } else { + logger.info("Sorting {} nodes for dictionary '{}'...", builder.getNodes().size(), name); + long sortStart = System.nanoTime(); + sorted = NodeSorter.parallelSort(builder.getNodes()); + logger.info("Sorted dictionary '{}' in {} s", name, (System.nanoTime() - sortStart) / 1_000_000_000L); + } // --- STEP 2: Initialize Buffers --- // BitPackedUnSignedLongBuffer constructors do not throw; assign finals directly. - this.offsets = new BitPackedUnSignedLongBuffer(Path.of("offsets"), null, 0, 1 + MinBits(builder.getNodes().size())); + this.offsets = new BitPackedUnSignedLongBuffer(Path.of("offsets"), null, 0, 1 + MinBits(builder.getNodeCount())); this.nativedatatypes = new BitPackedUnSignedLongBuffer(Path.of("datatypes"), null, 0, 1 + MinBits(DataType.values().length)); // Signed-safe widths: when min is negative, use a fixed width (32 or 64) so the two's-complement // bit pattern survives the unsigned mask round-trip in BitPackedUnSignedLongBuffer. @@ -347,24 +354,33 @@ public void add(WritableGroup group) { public static class Builder { private Set nodes = new HashSet<>(); + private ArrayList sortedNodes; private String name; private Stats stats; private Set et = new HashSet<>(); - private Set typedLiterals = new HashSet<>(); + private Set typedLiterals = new HashSet<>(); public Builder enable(Types... types) { et.addAll(Arrays.asList(types)); return this; } public Builder setStats(Stats stats) { this.stats = stats; return this; } public Builder setNodes(Set nodes) { this.nodes = nodes; return this; } + /** + * Supplies the node list ALREADY in {@code NodeComparator} order, skipping + * the internal sort; takes precedence over {@link #setNodes}. The caller + * owns the ordering contract - a mis-sorted list corrupts every id. + */ + public Builder setSortedNodes(ArrayList sortedNodes) { this.sortedNodes = sortedNodes; return this; } public Builder setDataTypes(Set typedLiterals) { this.typedLiterals = typedLiterals; return this; } public Builder setName(String name) { this.name = name; return this; } public String getName() { return name; } public Set getNodes() { return nodes; } + public ArrayList getSortedNodes() { return sortedNodes; } + public int getNodeCount() { return sortedNodes != null ? sortedNodes.size() : nodes.size(); } public Stats getStats() { return stats; } public Set getEnabledTypes() { return et; } public Set getTypedLiterals() { return typedLiterals; } - + public DictionaryWriter build() throws IOException { - if (nodes.isEmpty()) return new EmptyDictionaryWriter(); + if (getNodeCount() == 0) return new EmptyDictionaryWriter(); return new MultiTypeDictionaryWriter(this); } - } + } } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java index 12deb3cb..c7f18624 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java @@ -77,8 +77,9 @@ public class PositionalDictionaryWriterBuilder { // Sentinel base: relative references in the source are parsed against this // stable, reserved (.invalid) host that survives IRI normalization, then // stripped back to relative form for storage and resolved at query time - // against the URL the .h5 file is served from. - private static final String REL_BASE = "http://beakgraph.invalid/document"; + // against the URL the .h5 file is served from. Protected: the ultra + // subclass parses documents itself and must use the identical base. + protected static final String REL_BASE = "http://beakgraph.invalid/document"; private static final String REL_BASE_PREFIX = "http://beakgraph.invalid/"; private static final IRIx REL_BASE_IRIX = IRIx.create(REL_BASE); @@ -223,7 +224,10 @@ private List generateGridURNs(Polygon polygon, int resolutionLevel) { return intersectingURNs; } - private ArrayList addSpatial(Quad quad) { + // Protected, not private: thread-safe per-quad augmentation (guarded by the + // spatial/features flags only), reused by the ultra subclass's per-document + // parallel parse. Both callers already invoke it concurrently. + protected ArrayList addSpatial(Quad quad) { final ArrayList qqq = new ArrayList<>(); // The GeoSPARQL-standard " WKT" form must be indexed too: strip the // prefix once here so the parser and the scaler both see plain WKT @@ -404,7 +408,7 @@ private Quad AlignBnodes(Quad quad) { * {@code <>} becomes "" and a sibling {@code } becomes "x.png". They * are resolved against the serving URL at query time. */ - private Quad relativize(Quad q) { + protected final Quad relativize(Quad q) { Node qg = q.getGraph(); Node qs = q.getSubject(); Node qp = q.getPredicate(); @@ -453,7 +457,7 @@ private Node relativizeNode(Node n) { * and extract() symmetric. (The value-typed storage never preserved the * non-canonical lexical form anyway.) */ - private Quad canonicalizeNumericObject(Quad quad) { + protected final Quad canonicalizeNumericObject(Quad quad) { Node o = quad.getObject(); if (!o.isLiteral()) return quad; String dt = o.getLiteralDatatypeURI(); @@ -490,10 +494,76 @@ private static Object literalValueOrNull(Node o) { } } - private void countStringStored(String lex) { - this.stats.longestStringLength = Math.max(this.stats.longestStringLength, lex.length()); - this.stats.shortestStringLength = Math.min(this.stats.shortestStringLength, lex.length()); - this.stats.numStrings++; + private static void countStringStored(Stats stats, String lex) { + stats.longestStringLength = Math.max(stats.longestStringLength, lex.length()); + stats.shortestStringLength = Math.min(stats.shortestStringLength, lex.length()); + stats.numStrings++; + } + + /** + * Counts one DISTINCT literal into {@code stats}, choosing the same storage + * class (long/int/float/double/strings, with the ill-typed and dateTime + * special cases) that {@link MultiTypeDictionaryWriter} will pick when it + * encodes the node. Extracted from {@link #ProcessQuad} so the ultra + * writer's post-dedup, chunk-parallel stats pass counts literals with + * EXACTLY the sequential rules (a drifted copy here would corrupt buffer + * allocation, not just reporting). Must be called once per unique literal. + */ + protected static void countLiteralStats(Node o, Stats stats) { + String dt = o.getLiteralDatatypeURI(); + if (dt.equals(XSD.xlong.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxLong = Math.max(stats.maxLong, n.longValue()); + stats.minLong = Math.min(stats.minLong, n.longValue()); + stats.numLong++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xint.getURI())) { + // Only xsd:int (32-bit bounded) is bit-packed here. xsd:integer is + // unbounded, so it is handled by the string fallback below instead; + // bit-packing it would truncate large values and change the datatype + // to xsd:int on read-back. + if (literalValueOrNull(o) instanceof Number n) { + stats.maxInteger = Math.max(stats.maxInteger, n.intValue()); + stats.minInteger = Math.min(stats.minInteger, n.intValue()); + stats.numInteger++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xfloat.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxFloat = Math.max(stats.maxFloat, n.floatValue()); + stats.minFloat = Math.min(stats.minFloat, n.floatValue()); + stats.numFloat++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xdouble.getURI())) { + if (literalValueOrNull(o) instanceof Number n) { + stats.maxDouble = Math.max(stats.maxDouble, n.doubleValue()); + stats.minDouble = Math.min(stats.minDouble, n.doubleValue()); + stats.numDouble++; + } else { + countStringStored(stats, o.getLiteralLexicalForm()); // ill-typed: strings path + } + } else if (dt.equals(XSD.xstring.getURI()) || dt.equals(GEO.wktLiteral.getURI()) || dt.equals(XSD.xboolean.getURI()) || dt.equals(RDF.langString.getURI())) { + // rdf:langString shares the strings buffer; its language tag is + // stored separately by MultiTypeDictionaryWriter (langs/langTags). + countStringStored(stats, o.getLiteralLexicalForm()); + } else if (dt.equals(XSD.dateTime.getURI())) { + String lex = o.getLiteralLexicalForm(); + int t = lex.indexOf('T'); + countStringStored(stats, (t > 0) ? lex.substring(0, t) : lex); + } else { + // Any other datatype (xsd:integer, xsd:decimal, xsd:date, custom + // datatypes, ...) is stored verbatim in the strings buffer by + // MultiTypeDictionaryWriter, tagged with its datatype IRI. Count it + // toward numStrings so that buffer is always allocated; otherwise the + // writer would have nowhere to put it and would drop the node, + // desynchronising the offset/datatype buffers and corrupting the dictionary. + countStringStored(stats, o.getLiteralLexicalForm()); + } } private void ProcessQuad(Quad quad) { @@ -533,61 +603,8 @@ private void ProcessQuad(Quad quad) { } if (o.isLiteral()) { if (!literals.contains(o)) { - String dt = o.getLiteralDatatypeURI(); - dataTypes.add(dt); - if (dt.equals(XSD.xlong.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxLong = Math.max(this.stats.maxLong, n.longValue()); - this.stats.minLong = Math.min(this.stats.minLong, n.longValue()); - this.stats.numLong++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xint.getURI())) { - // Only xsd:int (32-bit bounded) is bit-packed here. xsd:integer is - // unbounded, so it is handled by the string fallback below instead; - // bit-packing it would truncate large values and change the datatype - // to xsd:int on read-back. - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxInteger = Math.max(this.stats.maxInteger, n.intValue()); - this.stats.minInteger = Math.min(this.stats.minInteger, n.intValue()); - this.stats.numInteger++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xfloat.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxFloat = Math.max(this.stats.maxFloat, n.floatValue()); - this.stats.minFloat = Math.min(this.stats.minFloat, n.floatValue()); - this.stats.numFloat++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xdouble.getURI())) { - if (literalValueOrNull(o) instanceof Number n) { - this.stats.maxDouble = Math.max(this.stats.maxDouble, n.doubleValue()); - this.stats.minDouble = Math.min(this.stats.minDouble, n.doubleValue()); - this.stats.numDouble++; - } else { - countStringStored(o.getLiteralLexicalForm()); // ill-typed: strings path - } - } else if (dt.equals(XSD.xstring.getURI()) || dt.equals(GEO.wktLiteral.getURI()) || dt.equals(XSD.xboolean.getURI()) || dt.equals(RDF.langString.getURI())) { - // rdf:langString shares the strings buffer; its language tag is - // stored separately by MultiTypeDictionaryWriter (langs/langTags). - countStringStored(o.getLiteralLexicalForm()); - } else if (dt.equals(XSD.dateTime.getURI())) { - String lex = o.getLiteralLexicalForm(); - int t = lex.indexOf('T'); - countStringStored((t > 0) ? lex.substring(0, t) : lex); - } else { - // Any other datatype (xsd:integer, xsd:decimal, xsd:date, custom - // datatypes, ...) is stored verbatim in the strings buffer by - // MultiTypeDictionaryWriter, tagged with its datatype IRI. Count it - // toward numStrings so that buffer is always allocated; otherwise the - // writer would have nowhere to put it and would drop the node, - // desynchronising the offset/datatype buffers and corrupting the dictionary. - countStringStored(o.getLiteralLexicalForm()); - } + dataTypes.add(o.getLiteralDatatypeURI()); + countLiteralStats(o, stats); literals.add(o); } } else { @@ -733,7 +750,7 @@ private void parseSource(File input, AtomicLong quadcount) throws IOException { } } - private boolean isGeoLiteral(Quad quad) { + protected final boolean isGeoLiteral(Quad quad) { Node o = quad.getObject(); return o.isLiteral() && GEO.wktLiteral.getURI().equals(o.getLiteralDatatypeURI()); } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java new file mode 100644 index 00000000..6b90f30a --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparator.java @@ -0,0 +1,56 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.lib.NodeComparator; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.expr.NodeValue; + +/** + * {@link NodeComparator} with a PRIVATE NodeValue memo, replacing Jena's one + * global bounded cache in the literal-comparison path. Two reasons, both + * discovered on a 1.4B-quad PubMed merge: + * + *
    + *
  • Contention: {@code NodeValue.makeNode} funnels every thread of a + * parallel term-run sort through the same Caffeine cache, whose eviction + * lock times out under load ("excessive wait times" warnings) and stalls + * ingestion behind the spill it is backpressured on.
  • + *
  • Work: a sort converts each literal on every comparison - + * O(n log n) makeNode calls; the memo converts each distinct node once + * per cap window.
  • + *
+ * + * Ordering is EXACTLY the parent's: the override only changes where the + * Node-to-NodeValue conversion result comes from. One instance per sorter; + * the map is cleared when it exceeds {@code maxEntries} (a sorter outlives + * many runs, so an uncapped memo would grow with the whole build's term + * population). + */ +final class CachingNodeComparator extends NodeComparator { + + private final int maxEntries; + private final ConcurrentHashMap memo; + + CachingNodeComparator(int maxEntries) { + this.maxEntries = maxEntries; + this.memo = new ConcurrentHashMap<>(1 << 16); + } + + @Override + protected NodeValue nodeValue(Node n) { + NodeValue v = memo.get(n); + if (v != null) { + return v; + } + v = NodeValue.makeNode(n); + if (memo.size() >= maxEntries) { + // Crude but contention-free pressure valve: a full reset costs one + // re-conversion per live node, an eviction policy would cost + // bookkeeping on EVERY hit. Sorted runs re-touch a node many times + // in a short window, so recency hardly matters here. + memo.clear(); + } + memo.put(n, v); + return v; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java new file mode 100644 index 00000000..dcf75a3d --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java @@ -0,0 +1,178 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import com.ebremer.beakgraph.huge.HugeBuildPipeline; +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The parallel disk-based BeakGraph writer (CLI: {@code -method 4}, threads + * via {@code -cores}) for graphs of billions of quads: the exact + * {@link HugeBuildPipeline} flow and output of {@code -method 1} - RAM stays + * bounded by spill-batch sizes regardless of quad count - but every heavy + * component is swapped for a parallel one: + * + *
    + *
  • spill runs sort and write on background workers while ingestion + * continues (double-buffered);
  • + *
  • the three column sorts, three dictionary encodes, and three id joins + * each run concurrently;
  • + *
  • encoded quads and (row, id) join records sort as bit-packed primitive + * keys - parallel radix sort per run, fixed-width binary spills, no + * objects ({@link PackedQuadSorter}, {@link PackedRowIdSorter});
  • + *
  • term runs group each distinct term's text once per run and compare via + * an order-preserving 8-byte prefix key ({@link UltraSorterProvider});
  • + *
  • GPOS sorts only the DEDUPLICATED quad set teed out of the GSPO + * scan.
  • + *
+ * + * Output is isomorphic to the other writers' (same divergence -method 1 has: + * blank nodes keep parsed labels rather than being relabelled). Requires the + * native HDF5 backend, like -method 1. Bigger spill batches = fewer merge + * levels = less disk churn; size them to your heap + * ({@code setIdSpillBatch(1 << 26)} at 16 GiB+ is reasonable for + * 10B+ quad builds). + * + * @author Erich Bremer + */ +public class HugeUltraHDF5Writer implements BeakGraphWriter { + + private static final Logger logger = LoggerFactory.getLogger(HugeUltraHDF5Writer.class); + + private final Builder builder; + + private HugeUltraHDF5Writer(Builder builder) { + this.builder = builder; + } + + @Override + public void write() throws IOException { + logger.info("Writing BeakGraph (hugeUltra: disk-based, {} cores) to {}", + builder.cores, builder.getDestination()); + Path dest = builder.getDestination().toPath(); + Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp"); + Path workBase = (builder.workDir != null) ? builder.workDir + : (dest.toAbsolutePath().getParent() != null ? dest.toAbsolutePath().getParent() : Path.of(".")); + Path workspace = Files.createTempDirectory(workBase, ".bghugeultra-"); + List inputs = builder.getSources().isEmpty() + ? List.of(builder.getSource()) + : builder.getSources(); + ForkJoinPool pool = new ForkJoinPool(builder.cores); + try { + UltraSorterProvider provider = new UltraSorterProvider( + builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn, pool); + try (HugeBuildPipeline pipeline = new HugeBuildPipeline( + inputs, builder.getSpatial(), builder.getFeatures(), workspace, provider, pool)) { + pipeline.run(tmp); + } + try { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + try { + Files.deleteIfExists(tmp); + } catch (IOException cleanup) { + logger.warn("Failed to remove temp output {}", tmp, cleanup); + } + throw ex; + } finally { + pool.shutdown(); + deleteRecursively(workspace); + } + logger.info("Write complete: {}", builder.getDestination()); + } + + private static void deleteRecursively(Path dir) { + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException { + Files.deleteIfExists(d); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + logger.warn("Failed to remove workspace {}", dir, e); + } + } + + public static class Builder extends AbstractGraphBuilder { + + private Path workDir; + private int cores = Math.max(2, Runtime.getRuntime().availableProcessors()); + private int termSpillBatch = 1 << 19; // 524288 term records per run + private int idSpillBatch = 1 << 22; // 4M packed id records per run + private int mergeFanIn = 128; + + /** Workspace for spill runs; needs disk on the order of a few times the source. */ + public Builder setWorkDirectory(Path dir) { + this.workDir = dir; + return this; + } + + /** Worker threads for sorting, spilling, merging, and concurrent stages. */ + public Builder setCores(int cores) { + if (cores < 1) throw new IllegalArgumentException("cores must be >= 1, got " + cores); + this.cores = cores; + return this; + } + + public Builder setTermSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("termSpillBatch must be >= 1"); + this.termSpillBatch = records; + return this; + } + + public Builder setIdSpillBatch(int records) { + if (records < 1) throw new IllegalArgumentException("idSpillBatch must be >= 1"); + this.idSpillBatch = records; + return this; + } + + public Builder setMergeFanIn(int fanIn) { + if (fanIn < 2) throw new IllegalArgumentException("mergeFanIn must be >= 2"); + this.mergeFanIn = fanIn; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public String getName() { + return Params.BG; + } + + @Override + public HugeUltraHDF5Writer build() { + return new HugeUltraHDF5Writer(this); + } + } + + public static Builder Builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java new file mode 100644 index 00000000..857bd712 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/PackedLongSorter.java @@ -0,0 +1,345 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.hdf5.writers.ultra.ParallelRadixSort; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.PriorityQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The disk-based primitive sorter at the heart of -method 4: records are + * unsigned keys of up to 126 bits (a {@code lo} word plus an optional + * {@code hi} word), so a "record" is one or two array slots - no objects, no + * codec, no comparator. RAM runs radix-sort in parallel + * ({@link ParallelRadixSort}) on a background worker while ingestion + * continues (double-buffered), spill runs are fixed-width big-endian binary, + * intermediate merges of independent run groups execute concurrently, and the + * final k-way merge streams through buffered fixed-width readers. + * + *

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

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

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

    + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedLongSorter} - + * external sorter over 1-2-word unsigned keys: parallel radix-sorted RAM + * runs written by background workers (double-buffered so ingestion never + * stalls), fixed-width binary spills, concurrent intermediate merges;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedQuadSorter} / + * {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.PackedRowIdSorter} - + * the id-record sorters bit-packed onto it (no objects, no comparators, + * no codecs in the hot path);
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.ParallelSpillSorter} - + * the term-column sorter: background spilling, concurrent merges, an + * order-preserving 8-byte prefix key that avoids most NodeComparator + * calls, and a grouped run format writing each distinct term's text once + * per run;
  • + *
  • {@link com.ebremer.beakgraph.hdf5.writers.hugeUltra.HugeUltraHDF5Writer} - + * the public entry point ({@code BeakGraphWriter}); owns the worker pool + * and the atomic tmp-then-move publish.
  • + *
+ * + * The independent pipeline stages (three column sorts, three dictionary + * encodes, three id joins) run concurrently on the same pool, and GPOS sorts + * only the deduplicated quads teed out of the GSPO scan. Output format and + * readers are unchanged; like -method 1 the native HDF5 backend is required + * and output is isomorphic (bnode labels are kept as parsed). + */ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java index 8638bc50..22c747ea 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java @@ -23,7 +23,7 @@ * Multi-threaded twin of {@link com.ebremer.beakgraph.hdf5.writers.HDF5Writer}: * same source formats, same output format, same readers - but each store is * built on a dedicated {@link ForkJoinPool} of {@code cores} threads (CLI: - * {@code -parallel} with {@code -cores}, default {@value #DEFAULT_CORES}). + * {@code -method 2} with {@code -cores}, default {@value #DEFAULT_CORES}). * The three sub-dictionaries build concurrently, the columnar id lists * populate concurrently, every quad's dictionary ids are resolved once in a * parallel pass, and the GSPO/GPOS indexes are then built concurrently from diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java index d6eebd40..a77f07be 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/package-info.java @@ -5,7 +5,7 @@ * build one store on one thread: three sub-dictionaries one after another, three * columnar id lists one after another, then the GSPO index, then the GPOS index. * This package is a drop-in twin of that pipeline that runs the independent - * stages concurrently on a bounded worker pool (CLI: {@code -parallel}, sized + * stages concurrently on a bounded worker pool (CLI: {@code -method 2}, sized * with {@code -cores}, default 4): * *
    diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java new file mode 100644 index 00000000..7769cd20 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/ParallelRadixSort.java @@ -0,0 +1,143 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.stream.IntStream; + +/** + * Parallel LSD radix sort for the ultra index's packed tuple keys: rows are + * unsigned keys of up to 128 bits held in a {@code lo} array and an optional + * {@code hi} array ({@code null} when the whole key fits 64 bits). O(n) per + * 16-bit digit pass instead of the O(n log n) comparisons of a comparator + * sort, and every pass parallelizes as per-chunk histogram, per-bucket prefix + * offsets, then per-chunk stable scatter. + * + *

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

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

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

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

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

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

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

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

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

      + *
    • Parsing - source documents parse concurrently (bnode alignment is + * per-document state by RDF semantics), and dictionary-set deduplication + * plus statistics run as parallel passes over the collected quads + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraIngest}).
    • + *
    • Id resolution - dictionary ids are 1-based NodeComparator ranks, + * so one shared sort per sub-dictionary feeds the storage encoder AND an + * O(1) node-to-id hash map; no binary search ever runs + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraDictionary}).
    • + *
    • Index sorting - each quad's four ids pack into one primitive key + * per ordering (one or two words), sorted by a parallel LSD radix sort or + * the JDK primitive sort; duplicates collapse once and GPOS reuses the + * deduplicated GSPO set + * ({@link com.ebremer.beakgraph.hdf5.writers.ultra.ParallelRadixSort}, + * {@link com.ebremer.beakgraph.hdf5.writers.ultra.UltraBGIndex}).
    • + *
    • Index emission - the level-emission scan becomes two parallel + * passes (count, prefix-sum, positional write) over byte-aligned buffers + * and an atomically-OR-merged bitmap; the SB/BB rank directories fall out + * of word popcount prefix sums.
    • + *
    + * + * All work runs on one dedicated {@code ForkJoinPool} of {@code -cores} + * threads, so with {@code -threads N} in the CLI, N conversions run at once, + * each capped at its own core budget. Given the same parsed quads the emitted + * buffers are byte-identical to the sequential writer's; whole files differ + * only through VoID's per-write random blank-node labels (single source) or + * per-document blank-node scoping (merged sources). + */ +package com.ebremer.beakgraph.hdf5.writers.ultra; diff --git a/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java b/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java index 829bd96e..b17fe477 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java +++ b/src/main/java/com/ebremer/beakgraph/huge/ExternalSorter.java @@ -35,23 +35,17 @@ * * @author Erich Bremer */ -final class ExternalSorter implements AutoCloseable { +public final class ExternalSorter implements RecordSorter { private static final Logger logger = LoggerFactory.getLogger(ExternalSorter.class); /** Serializes records for spill runs. {@code read} throws {@link EOFException} at run end. */ - interface Codec { + public interface Codec { void write(DataOutput out, T record) throws IOException; T read(DataInput in) throws IOException; } - /** A sorted record stream that owns temp files; must be closed. */ - interface SortedStream extends Iterator, AutoCloseable { - @Override - void close(); - } - private final Path workDir; private final String tag; private final Codec codec; @@ -64,8 +58,8 @@ interface SortedStream extends Iterator, AutoCloseable { private int runCounter = 0; private boolean consumed = false; - ExternalSorter(Path workDir, String tag, Codec codec, Comparator comparator, - int maxRecordsInMemory, int mergeFanIn) { + public ExternalSorter(Path workDir, String tag, Codec codec, Comparator comparator, + int maxRecordsInMemory, int mergeFanIn) { if (maxRecordsInMemory < 1 || mergeFanIn < 2) { throw new IllegalArgumentException("maxRecordsInMemory >= 1 and mergeFanIn >= 2 required"); } @@ -77,7 +71,8 @@ interface SortedStream extends Iterator, AutoCloseable { this.mergeFanIn = mergeFanIn; } - void add(T record) throws IOException { + @Override + public void add(T record) throws IOException { if (consumed) { throw new IllegalStateException("Sorter '" + tag + "' already consumed"); } @@ -89,7 +84,8 @@ void add(T record) throws IOException { } /** Total records added. */ - long size() { + @Override + public long size() { return size; } @@ -121,7 +117,8 @@ private void spillRun() throws IOException { * Finishes ingestion and returns the fully sorted stream. All-in-RAM inputs * (no spilled run) sort and iterate without touching disk. */ - SortedStream sorted() throws IOException { + @Override + public RecordSorter.SortedCursor sorted() throws IOException { if (consumed) { throw new IllegalStateException("Sorter '" + tag + "' already consumed"); } @@ -130,7 +127,7 @@ SortedStream sorted() throws IOException { T[] arr = sortedBufferArray(); buffer = new ArrayList<>(); Iterator it = Arrays.asList(arr).iterator(); - return new SortedStream() { + return new RecordSorter.SortedCursor() { @Override public boolean hasNext() { return it.hasNext(); } @Override public T next() { return it.next(); } @Override public void close() {} @@ -199,7 +196,7 @@ void closeAndDelete() { } } - private final class MergeIterator implements SortedStream { + private final class MergeIterator implements RecordSorter.SortedCursor { private final PriorityQueue heap; private final List readers = new ArrayList<>(); diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java index e0f031cc..23f3c50c 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java @@ -64,7 +64,7 @@ * * @author Erich Bremer */ -final class HugeBuildPipeline implements AutoCloseable { +public final class HugeBuildPipeline implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(HugeBuildPipeline.class); @@ -79,7 +79,6 @@ final class HugeBuildPipeline implements AutoCloseable { private final boolean spatial; private final boolean features; private final Path workDir; - private final int mergeFanIn; // ---- Pass A state ---- private final Stats stats = new Stats(); @@ -91,11 +90,13 @@ final class HugeBuildPipeline implements AutoCloseable { private final HashMap predTempIds = new HashMap<>(); private final ArrayList predByTempId = new ArrayList<>(); private final BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); - private ExternalSorter gSorter; - private ExternalSorter sSorter; - private ExternalSorter oSorter; + private RecordSorter gSorter; + private RecordSorter sSorter; + private RecordSorter oSorter; private RecordFile pTempFile; - private final int idSpillBatch; + private final SorterProvider provider; + /** Non-null: independent stage groups run concurrently on it (-method 4). */ + private final java.util.concurrent.ExecutorService stagePool; private long rows = 0; private long parsedQuads = 0; @@ -104,6 +105,19 @@ final class HugeBuildPipeline implements AutoCloseable { HugeBuildPipeline(List sources, boolean spatial, boolean features, Path workDir, int termSpillBatch, int idSpillBatch, int mergeFanIn) { + this(sources, spatial, features, workDir, + SorterProvider.sequential(termSpillBatch, idSpillBatch, mergeFanIn), null); + } + + /** + * The injectable form: {@code provider} supplies every sorter the build + * uses, and a non-null {@code stagePool} runs independent stage groups + * (column materializations, dictionary encodes, id joins) concurrently. + * This is how the hugeUltra writer (-method 4) turbocharges the pipeline + * without changing its flow or output. + */ + public HugeBuildPipeline(List sources, boolean spatial, boolean features, Path workDir, + SorterProvider provider, java.util.concurrent.ExecutorService stagePool) { if (sources.isEmpty()) { throw new IllegalArgumentException("At least one source document is required"); } @@ -111,23 +125,62 @@ final class HugeBuildPipeline implements AutoCloseable { this.spatial = spatial; this.features = features; this.workDir = workDir; - this.mergeFanIn = mergeFanIn; - this.gSorter = track(new ExternalSorter<>(workDir, "gcol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.sSorter = track(new ExternalSorter<>(workDir, "scol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.oSorter = track(new ExternalSorter<>(workDir, "ocol", HugeRecords.TERM_ROW_CODEC, - HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn)); - this.idSpillBatch = idSpillBatch; + this.provider = provider; + this.stagePool = stagePool; + this.gSorter = track(provider.termSorter(workDir, "gcol")); + this.sSorter = track(provider.termSorter(workDir, "scol")); + this.oSorter = track(provider.termSorter(workDir, "ocol")); } - private T track(T resource) { + // Synchronized: concurrent stages register their resources too. + private synchronized T track(T resource) { resources.add(resource); return resource; } + @FunctionalInterface + private interface Stage { + void run() throws IOException; + } + + /** + * Runs independent stages sequentially (no pool: -method 1 behaviour, + * bounded RAM) or concurrently on the stage pool (-method 4). Every stage + * is awaited before returning; the first failure wins. + */ + private void runStages(String what, Stage... stages) throws IOException { + if (stagePool == null) { + for (Stage s : stages) { + s.run(); + } + return; + } + List> futures = new ArrayList<>(stages.length); + for (Stage s : stages) { + futures.add(stagePool.submit(() -> { + s.run(); + return null; + })); + } + IOException first = null; + for (java.util.concurrent.Future f : futures) { + try { + f.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + if (first == null) first = new IOException("Interrupted during " + what, ex); + } catch (ExecutionException ex) { + Throwable c = ex.getCause(); + if (first == null) { + first = (c instanceof IOException io) ? io : new IOException(what + " failed", c); + } + } + } + if (first != null) throw first; + } + /** Runs the whole build, producing the finished HDF5 file at {@code tmpH5}. */ - void run(Path tmpH5) throws IOException { + public void run(Path tmpH5) throws IOException { ingest(); // ---- Predicates: final rank ids from the in-RAM population ---- @@ -146,13 +199,20 @@ void run(Path tmpH5) throws IOException { } // ---- Materialize the sorted columns (each is re-read several times) ---- - logger.info("Sorting {} rows per column (external merge sort)...", rows); - RecordFile gSorted = materialize(gSorter, "gcol.sorted"); + logger.info("Sorting {} rows per column (external merge sort{})...", rows, + stagePool == null ? "" : ", 3 columns concurrently"); + final List> cols = Arrays.asList(null, null, null); + final RecordSorter gS = gSorter, sS = sSorter, oS = oSorter; gSorter = null; - RecordFile sSorted = materialize(sSorter, "scol.sorted"); sSorter = null; - RecordFile oSorted = materialize(oSorter, "ocol.sorted"); oSorter = null; + runStages("column sort", + () -> cols.set(0, materialize(gS, "gcol.sorted")), + () -> cols.set(1, materialize(sS, "scol.sorted")), + () -> cols.set(2, materialize(oS, "ocol.sorted"))); + RecordFile gSorted = cols.get(0); + RecordFile sSorted = cols.get(1); + RecordFile oSorted = cols.get(2); // ---- Distinct sorted dictionaries on disk ---- RecordFile entFile = new RecordFile<>(workDir.resolve("entities.sorted"), NodeCodec.INSTANCE); @@ -166,30 +226,38 @@ void run(Path tmpH5) throws IOException { logger.info("Dictionary populations: {} entities, {} predicates, {} literals", numEntities, numPredicates, numLiterals); - // ---- Encode the three dictionary sections ---- - StreamingDictionaryWriter entitiesDict = null; - StreamingDictionaryWriter predicatesDict = null; - StreamingDictionaryWriter literalsDict = null; - if (numEntities > 0) { - entitiesDict = track(new StreamingDictionaryWriter(workDir, "entities", numEntities, stats, - Set.of(Types.IRI, Types.BNODE), new TreeSet<>(), new TreeSet<>())); - try (var s = entFile.read()) { - entitiesDict.encode(s); - } - } - if (numPredicates > 0) { - predicatesDict = track(new StreamingDictionaryWriter(workDir, "predicates", numPredicates, stats, - Set.of(Types.IRI), new TreeSet<>(), new TreeSet<>())); - predicatesDict.encode(Arrays.asList(sortedPreds).iterator()); - } - if (numLiterals > 0) { - literalsDict = track(new StreamingDictionaryWriter(workDir, "literals", numLiterals, stats, - Set.of(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING), - dataTypes, langSet)); - try (var s = litFile.read()) { - literalsDict.encode(s); - } - } + // ---- Encode the three dictionary sections (independent; concurrent with a pool) ---- + final StreamingDictionaryWriter[] dicts = new StreamingDictionaryWriter[3]; + runStages("dictionary encode", + () -> { + if (numEntities > 0) { + dicts[0] = track(new StreamingDictionaryWriter(workDir, "entities", numEntities, stats, + Set.of(Types.IRI, Types.BNODE), new TreeSet<>(), new TreeSet<>())); + try (var s = entFile.read()) { + dicts[0].encode(s); + } + } + }, + () -> { + if (numPredicates > 0) { + dicts[1] = track(new StreamingDictionaryWriter(workDir, "predicates", numPredicates, stats, + Set.of(Types.IRI), new TreeSet<>(), new TreeSet<>())); + dicts[1].encode(Arrays.asList(sortedPreds).iterator()); + } + }, + () -> { + if (numLiterals > 0) { + dicts[2] = track(new StreamingDictionaryWriter(workDir, "literals", numLiterals, stats, + Set.of(Types.DOUBLE, Types.FLOAT, Types.LONG, Types.INTEGER, Types.STRING), + dataTypes, langSet)); + try (var s = litFile.read()) { + dicts[2].encode(s); + } + } + }); + StreamingDictionaryWriter entitiesDict = dicts[0]; + StreamingDictionaryWriter predicatesDict = dicts[1]; + StreamingDictionaryWriter literalsDict = dicts[2]; // ---- Columnar unique-id lists (same widths as PositionalDictionaryWriter) ---- int gBits = (int) (Math.ceil(MinBits(numEntities + 1) / 8.0) * 8); @@ -199,44 +267,43 @@ void run(Path tmpH5) throws IOException { SpillBitPackedBuffer subjectsList = track(new SpillBitPackedBuffer(workDir.resolve("columnar.subjects"), sBits)); SpillBitPackedBuffer objectsList = track(new SpillBitPackedBuffer(workDir.resolve("columnar.objects"), oBits)); - // ---- Sort-merge id joins ---- - logger.info("Assigning ids (sort-merge joins)..."); - ExternalSorter gIds = track(new ExternalSorter<>(workDir, "gid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(gSorted, "Graph", entityCursor(entFile, null, numEntities), graphsList, gIds); + // ---- Sort-merge id joins (independent columns; concurrent with a pool), + // plus the predicate temp->final remap, which depends on nothing here ---- + logger.info("Assigning ids (sort-merge joins{})...", stagePool == null ? "" : ", 3 columns concurrently"); + RecordSorter gIds = track(provider.rowIdSorter(workDir, "gid", rows, numObjects)); + RecordSorter sIds = track(provider.rowIdSorter(workDir, "sid", rows, numObjects)); + RecordSorter oIds = track(provider.rowIdSorter(workDir, "oid", rows, numObjects)); + RecordFile pFinal = new RecordFile<>(workDir.resolve("pcol.final"), HugeRecords.VAR_LONG_CODEC); + runStages("id join", + () -> joinColumn(gSorted, "Graph", entityCursor(entFile, null, numEntities), graphsList, gIds), + () -> joinColumn(sSorted, "Subject", entityCursor(entFile, null, numEntities), subjectsList, sIds), + () -> joinColumn(oSorted, "Object", entityCursor(entFile, litFile, numEntities), objectsList, oIds), + () -> { + try (var s = pTempFile.read()) { + while (s.hasNext()) { + pFinal.append(tempToFinal[Math.toIntExact(s.next())]); + } + } + pFinal.finish(); + }); gSorted.delete(); - - ExternalSorter sIds = track(new ExternalSorter<>(workDir, "sid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(sSorted, "Subject", entityCursor(entFile, null, numEntities), subjectsList, sIds); sSorted.delete(); - - ExternalSorter oIds = track(new ExternalSorter<>(workDir, "oid", HugeRecords.ROW_ID_CODEC, - HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn)); - joinColumn(oSorted, "Object", entityCursor(entFile, litFile, numEntities), objectsList, oIds); oSorted.delete(); entFile.delete(); litFile.delete(); - - // ---- Predicate column: temp ids -> final ids, already in row order ---- - RecordFile pFinal = new RecordFile<>(workDir.resolve("pcol.final"), HugeRecords.VAR_LONG_CODEC); - try (var s = pTempFile.read()) { - while (s.hasNext()) { - pFinal.append(tempToFinal[Math.toIntExact(s.next())]); - } - } - pFinal.finish(); pTempFile.delete(); if (pFinal.count() != rows) { throw new IllegalStateException("Predicate column has " + pFinal.count() + " entries for " + rows + " rows"); } - // ---- Zip id columns into encoded quads, feeding both index sorters ---- + // ---- Zip id columns into encoded quads. Only GSPO sorts the raw + // (duplicate-laden) quads: the GSPO scan below tees each DISTINCT quad + // into the GPOS sorter, so GPOS sorts the deduplicated set only ---- logger.info("Encoding {} quads...", rows); - ExternalSorter gspoSorter = track(new ExternalSorter<>(workDir, "gspo", HugeRecords.ID_QUAD_CODEC, - HugeRecords.GSPO_ORDER, idSpillBatch, mergeFanIn)); - ExternalSorter gposSorter = track(new ExternalSorter<>(workDir, "gpos", HugeRecords.ID_QUAD_CODEC, - HugeRecords.GPOS_ORDER, idSpillBatch, mergeFanIn)); + RecordSorter gspoSorter = track(provider.quadSorter(workDir, "gspo", Index.GSPO, + numEntities, numPredicates, numObjects)); + RecordSorter gposSorter = track(provider.quadSorter(workDir, "gpos", Index.GPOS, + numEntities, numPredicates, numObjects)); try (var gs = gIds.sorted(); var ss = sIds.sorted(); var os = oIds.sorted(); var ps = pFinal.read()) { for (long row = 0; row < rows; row++) { RowId g = gs.next(); @@ -247,9 +314,7 @@ void run(Path tmpH5) throws IOException { throw new IllegalStateException("Row misalignment at " + row + ": g=" + g.row() + " s=" + s.row() + " o=" + o.row()); } - IdQuad q = new IdQuad(g.id(), s.id(), p, o.id()); - gspoSorter.add(q); - gposSorter.add(q); + gspoSorter.add(new IdQuad(g.id(), s.id(), p, o.id())); } } pFinal.delete(); @@ -258,7 +323,7 @@ void run(Path tmpH5) throws IOException { HugeIndexWriter gspo = track(new HugeIndexWriter(workDir, Index.GSPO, numEntities, numEntities, numPredicates, numObjects, rows)); try (var it = gspoSorter.sorted()) { - gspo.build(it); + gspo.build(teeDistinctTo(it, gposSorter)); } HugeIndexWriter gpos = track(new HugeIndexWriter(workDir, Index.GPOS, numEntities, numEntities, numPredicates, numObjects, rows)); @@ -574,9 +639,40 @@ private Quad canonicalizeNumericObject(Quad quad) { // Phase helpers // ------------------------------------------------------------------ - private RecordFile materialize(ExternalSorter sorter, String name) throws IOException { + /** + * Forwards {@code sorted} unchanged while adding each DISTINCT quad to + * {@code alsoDistinct} (duplicates are consecutive in a sorted stream). + * This is the dedup-once trick: GPOS receives exactly the unique quads the + * GSPO scan keeps, so it never sorts a duplicate. + */ + private static Iterator teeDistinctTo(Iterator sorted, RecordSorter alsoDistinct) { + return new Iterator<>() { + private IdQuad prev = null; + + @Override + public boolean hasNext() { + return sorted.hasNext(); + } + + @Override + public IdQuad next() { + IdQuad q = sorted.next(); + if (prev == null || !q.equals(prev)) { + try { + alsoDistinct.add(q); + } catch (IOException e) { + throw new UncheckedIOException("Failed to tee distinct quad into GPOS sorter", e); + } + prev = q; + } + return q; + } + }; + } + + private RecordFile materialize(RecordSorter sorter, String name) throws IOException { RecordFile rf = new RecordFile<>(workDir.resolve(name), HugeRecords.TERM_ROW_CODEC); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { while (s.hasNext()) { rf.append(s.next()); } @@ -755,7 +851,7 @@ public void close() { * ascending id order - into the columnar list. */ private void joinColumn(RecordFile column, String role, DictCursor cursor, - SpillBitPackedBuffer columnar, ExternalSorter out) throws IOException { + SpillBitPackedBuffer columnar, RecordSorter out) throws IOException { try (cursor; var col = column.read()) { Node prevTerm = null; long prevId = -1; diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java b/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java index a62e20f8..b46e881e 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeIO.java @@ -15,7 +15,7 @@ * * @author Erich Bremer */ -final class HugeIO { +public final class HugeIO { /** Copy buffer for temp-file-to-HDF5 streaming; bounds writer RAM per transfer. */ static final int TRANSFER_BUFFER_BYTES = 8 * 1024 * 1024; @@ -34,7 +34,7 @@ static void copyFileIntoDataset(Path file, StreamingHdf5Dataset ds) throws IOExc } /** Unsigned LEB128-style varint (7 bits per byte, high bit = continuation). */ - static void writeVarLong(DataOutput out, long value) throws IOException { + public static void writeVarLong(DataOutput out, long value) throws IOException { if (value < 0) { throw new IllegalArgumentException("varint value must be non-negative: " + value); } @@ -45,7 +45,7 @@ static void writeVarLong(DataOutput out, long value) throws IOException { out.writeByte((int) value); } - static long readVarLong(DataInput in) throws IOException { + public static long readVarLong(DataInput in) throws IOException { long result = 0; int shift = 0; while (true) { @@ -71,3 +71,4 @@ static byte[] readBytes(DataInput in, int len) throws IOException { return data; } } + diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java b/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java index 8dc09473..cbde5032 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeRecords.java @@ -22,35 +22,35 @@ * * @author Erich Bremer */ -final class HugeRecords { +public final class HugeRecords { private HugeRecords() {} - record TermRow(Node term, long row) {} + public record TermRow(Node term, long row) {} - record RowId(long row, long id) {} + public record RowId(long row, long id) {} - record IdQuad(long g, long s, long p, long o) {} + public record IdQuad(long g, long s, long p, long o) {} /** Term order only; equal terms may interleave rows arbitrarily (the join maps them to one id). */ - static final Comparator TERM_ORDER = + public static final Comparator TERM_ORDER = (a, b) -> NodeComparator.INSTANCE.compare(a.term(), b.term()); - static final Comparator ROW_ORDER = Comparator.comparingLong(RowId::row); + public static final Comparator ROW_ORDER = Comparator.comparingLong(RowId::row); - static final Comparator GSPO_ORDER = Comparator + public static final Comparator GSPO_ORDER = Comparator .comparingLong(IdQuad::g) .thenComparingLong(IdQuad::s) .thenComparingLong(IdQuad::p) .thenComparingLong(IdQuad::o); - static final Comparator GPOS_ORDER = Comparator + public static final Comparator GPOS_ORDER = Comparator .comparingLong(IdQuad::g) .thenComparingLong(IdQuad::p) .thenComparingLong(IdQuad::o) .thenComparingLong(IdQuad::s); - static final ExternalSorter.Codec TERM_ROW_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec TERM_ROW_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, TermRow record) throws IOException { NodeCodec.writeNode(out, record.term()); @@ -65,7 +65,7 @@ public TermRow read(DataInput in) throws IOException { } }; - static final ExternalSorter.Codec ROW_ID_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec ROW_ID_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, RowId record) throws IOException { HugeIO.writeVarLong(out, record.row()); @@ -81,7 +81,7 @@ public RowId read(DataInput in) throws IOException { }; /** Bare non-negative longs (the per-row predicate temp/final id files). */ - static final ExternalSorter.Codec VAR_LONG_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec VAR_LONG_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, Long record) throws IOException { HugeIO.writeVarLong(out, record); @@ -93,7 +93,7 @@ public Long read(DataInput in) throws IOException { } }; - static final ExternalSorter.Codec ID_QUAD_CODEC = new ExternalSorter.Codec<>() { + public static final ExternalSorter.Codec ID_QUAD_CODEC = new ExternalSorter.Codec<>() { @Override public void write(DataOutput out, IdQuad record) throws IOException { HugeIO.writeVarLong(out, record.g()); diff --git a/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java b/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java index a7309d8a..a5d096f0 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java +++ b/src/main/java/com/ebremer/beakgraph/huge/NodeCodec.java @@ -21,7 +21,7 @@ * * @author Erich Bremer */ -final class NodeCodec implements ExternalSorter.Codec { +public final class NodeCodec implements ExternalSorter.Codec { static final NodeCodec INSTANCE = new NodeCodec(); @@ -43,7 +43,7 @@ public Node read(DataInput in) throws IOException { return readNode(in); } - static void writeNode(DataOutput out, Node n) throws IOException { + public static void writeNode(DataOutput out, Node n) throws IOException { if (n.isURI()) { out.writeByte(T_URI); writeString(out, n.getURI()); @@ -68,7 +68,7 @@ static void writeNode(DataOutput out, Node n) throws IOException { } } - static Node readNode(DataInput in) throws IOException { + public static Node readNode(DataInput in) throws IOException { byte tag = in.readByte(); return switch (tag) { case T_URI -> NodeFactory.createURI(readString(in)); @@ -103,3 +103,4 @@ static String readString(DataInput in) throws IOException { return new String(HugeIO.readBytes(in, (int) len), StandardCharsets.UTF_8); } } + diff --git a/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java b/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java index 8d2f8b8a..3ad80ca5 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java +++ b/src/main/java/com/ebremer/beakgraph/huge/RecordFile.java @@ -62,13 +62,13 @@ long count() { } /** A fresh sequential stream over all records; close it when done. */ - ExternalSorter.SortedStream read() throws IOException { + RecordSorter.SortedCursor read() throws IOException { if (!finished) { throw new IllegalStateException("RecordFile not finished: " + path); } DataInputStream in = new DataInputStream( new BufferedInputStream(Files.newInputStream(path), 1 << 16)); - return new ExternalSorter.SortedStream() { + return new RecordSorter.SortedCursor() { private T head = advance(); private boolean closed = false; diff --git a/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java b/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java new file mode 100644 index 00000000..74f35d7c --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/huge/RecordSorter.java @@ -0,0 +1,34 @@ +package com.ebremer.beakgraph.huge; + +import java.io.IOException; +import java.util.Iterator; + +/** + * A disk-backed sorter of records of type {@code T}: records stream in via + * {@link #add}, and {@link #sorted()} - callable once - returns them in order. + * Implementations may buffer, spill, and merge however they like; RAM must + * stay bounded regardless of record count. + * + *

    Extracted from {@link ExternalSorter} so the build pipeline can run with + * pluggable sorting engines: the sequential external merge sort (-method 1) + * or the hugeUltra package's parallel/primitive sorters (-method 4). + */ +public interface RecordSorter extends AutoCloseable { + + /** A sorted record stream that owns its temp files; must be closed. */ + interface SortedCursor extends Iterator, AutoCloseable { + @Override + void close(); + } + + void add(T record) throws IOException; + + /** Total records added so far. */ + long size(); + + /** Finishes ingestion and returns the fully sorted stream (single use). */ + SortedCursor sorted() throws IOException; + + @Override + void close(); +} diff --git a/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java b/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java new file mode 100644 index 00000000..bd9a2c9a --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/huge/SorterProvider.java @@ -0,0 +1,57 @@ +package com.ebremer.beakgraph.huge; + +import com.ebremer.beakgraph.hdf5.Index; +import java.nio.file.Path; + +/** + * Factory for the sorters {@link HugeBuildPipeline} runs on. The pipeline + * tells the provider what is being sorted and (for the id-record sorters) the + * value ranges involved, so implementations can pick representations - the + * default wraps the sequential {@link ExternalSorter}; the hugeUltra provider + * returns background-spilling parallel sorters and bit-packed primitive + * sorters instead. + */ +public interface SorterProvider { + + /** Sorter for one term column's (term, row) records, in NodeComparator term order. */ + RecordSorter termSorter(Path workDir, String tag); + + /** + * Sorter for (row, id) join results back into row order. + * {@code maxRow}/{@code maxId} bound the values that will be added. + */ + RecordSorter rowIdSorter(Path workDir, String tag, long maxRow, long maxId); + + /** + * Sorter for dictionary-encoded quads in {@code order}'s component order. + * The three counts bound the ids (graph/subject ids ≤ numEntities, + * predicate ids ≤ numPredicates, object ids ≤ numObjects). + */ + RecordSorter quadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects); + + /** The sequential engine used by -method 1: plain {@link ExternalSorter}s. */ + static SorterProvider sequential(int termSpillBatch, int idSpillBatch, int mergeFanIn) { + return new SorterProvider() { + @Override + public RecordSorter termSorter(Path workDir, String tag) { + return new ExternalSorter<>(workDir, tag, HugeRecords.TERM_ROW_CODEC, + HugeRecords.TERM_ORDER, termSpillBatch, mergeFanIn); + } + + @Override + public RecordSorter rowIdSorter(Path workDir, String tag, long maxRow, long maxId) { + return new ExternalSorter<>(workDir, tag, HugeRecords.ROW_ID_CODEC, + HugeRecords.ROW_ORDER, idSpillBatch, mergeFanIn); + } + + @Override + public RecordSorter quadSorter(Path workDir, String tag, Index order, + long numEntities, long numPredicates, long numObjects) { + return new ExternalSorter<>(workDir, tag, HugeRecords.ID_QUAD_CODEC, + order == Index.GSPO ? HugeRecords.GSPO_ORDER : HugeRecords.GPOS_ORDER, + idSpillBatch, mergeFanIn); + } + }; + } +} diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java index ed62fd03..f544625f 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java @@ -77,25 +77,24 @@ void hugeFlagConvertsThroughDiskBasedWriter() throws Exception { } } - @Test - void parallelFlagConvertsThroughParallelWriter() throws Exception { - Path src = Files.createDirectories(dir.resolve("srcpar")); + private void convertsWithMethod(int method, String tag) throws Exception { + Path src = Files.createDirectories(dir.resolve("src" + tag)); Files.write(src.resolve("data.ttl"), " .\n".getBytes(StandardCharsets.UTF_8)); Parameters p = new Parameters(); p.src = src.toFile(); - p.dest = dir.resolve("outpar").toFile(); - p.parallel = true; + p.dest = dir.resolve("out" + tag).toFile(); + p.method = method; p.cores = 2; BeakGraphCLI cli = new BeakGraphCLI(p); cli.traverse(); assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), - "the -parallel conversion must succeed"); - File h5 = dir.resolve("outpar").resolve("data.h5").toFile(); - assertTrue(h5.exists() && h5.length() > 0, "the -parallel writer must produce the .h5 file"); + "the -method " + method + " conversion must succeed"); + File h5 = dir.resolve("out" + tag).resolve("data.h5").toFile(); + assertTrue(h5.exists() && h5.length() > 0, "the -method " + method + " writer must produce the .h5 file"); // The produced store must be readable by the standard reader stack. try (com.ebremer.beakgraph.core.BeakGraph bg = new com.ebremer.beakgraph.core.BeakGraph( new com.ebremer.beakgraph.hdf5.readers.HDF5Reader(h5))) { @@ -107,14 +106,37 @@ void parallelFlagConvertsThroughParallelWriter() throws Exception { } @Test - void parallelAndCoresOptionsParse() { - // The exact spellings the writer is documented with: -parallel and -cores. + void methodTwoConvertsThroughParallelWriter() throws Exception { + convertsWithMethod(2, "par"); + } + + @Test + void methodThreeConvertsThroughUltraWriter() throws Exception { + convertsWithMethod(3, "ultra"); + } + + @Test + void methodAndCoresOptionsParse() { + // The exact spellings the writers are documented with: -method and -cores. Parameters p = new Parameters(); com.beust.jcommander.JCommander.newBuilder().addObject(p).build() - .parse("-src", "x", "-parallel", "-cores", "6"); - assertTrue(p.parallel, "-parallel must set the flag"); + .parse("-src", "x", "-method", "3", "-cores", "6"); + assertEquals(3, p.method, "-method must set the engine"); assertEquals(6, p.cores, "-cores must override the default"); assertEquals(4, new Parameters().cores, "-cores must default to 4"); + assertEquals(0, new Parameters().method, "-method must default to the in-memory writer"); + org.junit.jupiter.api.Assertions.assertThrows(com.beust.jcommander.ParameterException.class, + () -> com.beust.jcommander.JCommander.newBuilder().addObject(new Parameters()).build() + .parse("-src", "x", "-method", "5"), + "-method outside 0..4 must be rejected"); + } + + @Test + void methodFourConvertsThroughHugeUltraWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + convertsWithMethod(4, "hugeultra"); } @Test diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java index ca376c11..52040251 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java @@ -179,7 +179,7 @@ void mergeWithParallelWriter() throws Exception { p.src = src.toFile(); p.dest = dir.resolve("outmergepar").resolve("all.h5").toFile(); p.merge = true; - p.parallel = true; + p.method = 2; p.cores = 2; BeakGraphCLI cli = new BeakGraphCLI(p); @@ -189,6 +189,23 @@ void mergeWithParallelWriter() throws Exception { assertMerged(p.dest); } + @Test + void mergeWithUltraWriter() throws Exception { + Path src = mergeSourceTree("srcmergeultra"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergeultra").resolve("all.h5").toFile(); + p.merge = true; + p.method = 3; + p.cores = 2; + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the ultra merge must succeed"); + assertMerged(p.dest); + } + @Test void mergeWithHugeWriter() throws Exception { org.junit.jupiter.api.Assumptions.assumeTrue( @@ -209,6 +226,27 @@ void mergeWithHugeWriter() throws Exception { assertMerged(p.dest); } + @Test + void mergeWithHugeUltraWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergehugeultra"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergehugeultra").resolve("all.h5").toFile(); + p.merge = true; + p.method = 4; + p.cores = 3; + p.workdir = dir.resolve("workmergehugeultra").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -method 4 merge must succeed"); + assertMerged(p.dest); + } + @Test void mergeIntoExistingDirectoryWritesMergedH5() throws Exception { Path src = mergeSourceTree("srcmergedir"); diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java new file mode 100644 index 00000000..0bc69c59 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/CachingNodeComparatorTest.java @@ -0,0 +1,83 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.lib.NodeComparator; +import java.util.ArrayList; +import java.util.List; +import org.apache.jena.datatypes.xsd.XSDDatatype; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.sparql.core.Quad; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +/** + * The caching comparator must be ORDER-IDENTICAL to the shared + * NodeComparator on every node kind - it only relocates where the + * Node-to-NodeValue conversion result is stored. Includes the value spaces + * with special-cased ordering (temporal, duration) and value-equal but + * term-distinct literals, plus the memo's cap-reset path. + */ +class CachingNodeComparatorTest { + + private static List zoo() { + List nodes = new ArrayList<>(); + nodes.add(Quad.defaultGraphIRI); + nodes.add(Quad.defaultGraphNodeGenerated); + nodes.add(NodeFactory.createBlankNode("b0")); + nodes.add(NodeFactory.createBlankNode("b1")); + nodes.add(NodeFactory.createURI("http://ex.org/a")); + nodes.add(NodeFactory.createURI("http://ex.org/b")); + nodes.add(NodeFactory.createURI("relative.png")); + nodes.add(NodeFactory.createLiteralString("alpha")); + nodes.add(NodeFactory.createLiteralString("beta")); + nodes.add(NodeFactory.createLiteralLang("alpha", "en")); + nodes.add(NodeFactory.createLiteralDT("1", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("1", XSDDatatype.XSDlong)); + nodes.add(NodeFactory.createLiteralDT("1.0", XSDDatatype.XSDdouble)); + nodes.add(NodeFactory.createLiteralDT("2", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("10", XSDDatatype.XSDint)); + nodes.add(NodeFactory.createLiteralDT("123456789012345678901234567890", XSDDatatype.XSDinteger)); + nodes.add(NodeFactory.createLiteralDT("abc", XSDDatatype.XSDint)); // ill-typed + nodes.add(NodeFactory.createLiteralDT("2020-01-02T00:00:00", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-02T08:00:00+14:00", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-01T20:00:00Z", XSDDatatype.XSDdateTime)); + nodes.add(NodeFactory.createLiteralDT("2020-01-02", XSDDatatype.XSDdate)); + nodes.add(NodeFactory.createLiteralDT("P1D", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("PT24H", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("P1M", XSDDatatype.XSDduration)); + nodes.add(NodeFactory.createLiteralDT("true", XSDDatatype.XSDboolean)); + return nodes; + } + + @Test + void orderIsIdenticalToTheSharedComparator() { + CachingNodeComparator cached = new CachingNodeComparator(1 << 16); + List nodes = zoo(); + for (Node a : nodes) { + for (Node b : nodes) { + int expected = Integer.signum(NodeComparator.INSTANCE.compare(a, b)); + assertEquals(expected, Integer.signum(cached.compare(a, b)), + "order of (" + a + ", " + b + ")"); + // Memo hits must not change the answer either. + assertEquals(expected, Integer.signum(cached.compare(a, b)), + "cached order of (" + a + ", " + b + ")"); + } + } + } + + @Test + void capResetKeepsAnswersCorrect() { + // A cap of 2 forces constant clear/reconvert cycles. + CachingNodeComparator cached = new CachingNodeComparator(2); + List nodes = zoo(); + for (int round = 0; round < 3; round++) { + for (int i = 0; i < nodes.size(); i++) { + for (int j = 0; j < nodes.size(); j++) { + assertEquals( + Integer.signum(NodeComparator.INSTANCE.compare(nodes.get(i), nodes.get(j))), + Integer.signum(cached.compare(nodes.get(i), nodes.get(j)))); + } + } + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java new file mode 100644 index 00000000..7ca689bb --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraWriterParityTest.java @@ -0,0 +1,214 @@ +package com.ebremer.beakgraph.hdf5.writers.hugeUltra; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.huge.NativeHdf5File; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity of the hugeUltra writer (-method 4) against the + * sequential in-memory writer, read back through the unmodified readers. The + * bar is the huge writer's: same graph lists, isomorphic per-graph content, + * same query answers (bnode labels legitimately differ - the disk pipeline + * keeps parsed labels). The interesting part: builds run with TINY spill + * batches and fan-in 2, so every new mechanism actually executes - background + * double-buffered spills, multi-level CONCURRENT merges, the grouped term-run + * format across merge levels, packed radix-sorted quad/row-id runs, and the + * GSPO-to-GPOS dedup tee (the input contains duplicate quads on purpose). + */ +class HugeUltraWriterParityTest { + + @TempDir + static Path dir; + + @BeforeAll + static void requireNativeHdf5() { + Assumptions.assumeTrue(NativeHdf5File.isAvailable(), "native HDF5 library unavailable"); + } + + private static Path writeBoth(String name, String content) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File hu = dir.resolve(name + ".hugeultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + HugeUltraHDF5Writer.Builder() + .setSource(src) + .setDestination(hu) + .setWorkDirectory(Files.createDirectories(dir.resolve("work-" + name))) + .setCores(3) + // Deliberately absurd sizing: hundreds of runs, several merge + // levels, constant background spilling. + .setTermSpillBatch(64) + .setIdSpillBatch(128) + .setMergeFanIn(2) + .build() + .write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + private static void assertStoresEquivalent(Path seqH5, Path huH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(huH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", hugeUltra=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", hugeUltra=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + @Test + void mixedTypesNamedGraphsAndBnodes() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """; + writeBoth("hu-mixed.trig", trig); + assertStoresEquivalent(dir.resolve("hu-mixed.trig.seq.h5"), dir.resolve("hu-mixed.trig.hugeultra.h5"), + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + } + + @Test + void stressManyQuadsWithDuplicatesThroughTinySpillRuns() throws Exception { + StringBuilder nq = new StringBuilder(1 << 21); + for (int i = 0; i < 12_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 300) + "\""; + case 2 -> "\"" + (i % 500 - 250) + "\"^^"; + default -> "\"" + ((i % 90) / 4.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + // Every 10th quad appears twice: the GSPO->GPOS dedup tee must + // collapse these without dropping anything else. + if (i % 10 == 0) { + nq.append(s).append(' ').append(p).append(' ').append(o).append(" .\n"); + } + } + writeBoth("hu-stress.nq", nq.toString()); + assertStoresEquivalent(dir.resolve("hu-stress.nq.seq.h5"), dir.resolve("hu-stress.nq.hugeultra.h5"), + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + } + + @Test + void mergeKeepsBlankNodesDistinctPerDocument() throws Exception { + Path src = Files.createDirectories(dir.resolve("hu-mergesrc")); + Files.write(src.resolve("m1.ttl"), + "_:b0 \"v1\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m2.ttl"), + "_:b0 \"v2\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + File out = dir.resolve("hu-merged.h5").toFile(); + HugeUltraHDF5Writer.Builder() + .setSources(java.util.List.of(src.resolve("m1.ttl").toFile(), src.resolve("m2.ttl").toFile())) + .setDestination(out) + .setWorkDirectory(Files.createDirectories(dir.resolve("hu-mergework"))) + .setCores(2) + .setTermSpillBatch(8) + .setIdSpillBatch(8) + .setMergeFanIn(2) + .build() + .write(); + try (BeakGraph bg = new BeakGraph(new HDF5Reader(out))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create( + "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }")).build()) { + assertEquals(2, qe.execSelect().next().getLiteral("n").getInt(), + "_:b0 from two documents must remain two distinct blank nodes"); + } + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java new file mode 100644 index 00000000..5e70caac --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraInternalsTest.java @@ -0,0 +1,218 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.Dictionary; +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.io.ByteBufferBytes; +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import org.apache.jena.graph.Node; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Direct verification of the ultra building blocks the end-to-end parity + * tests exercise only implicitly: the radix sort against the JDK sort + * (including the 2-word key path that small test stores never trigger), the + * 128-bit pack/extract round trip across the lo/hi straddle, byte-level + * compatibility of the positional buffers with the sequential bit-packed + * reader, and the id maps against the storage dictionaries' binary-search + * {@code locate()}. + */ +class UltraInternalsTest { + + @TempDir + Path dir; + + @Test + void radixSortSingleWordMatchesJdkSort() { + Random rnd = new Random(42); + int n = 200_000; // large enough to engage multi-chunk histograms + long[] keys = new long[n]; + for (int i = 0; i < n; i++) { + // Mixed magnitudes plus deliberate duplicates + keys[i] = switch (i % 4) { + case 0 -> rnd.nextLong() & 0x7FFFFFFFFFFFFFFFL; + case 1 -> rnd.nextInt(1000); + case 2 -> keys[Math.max(0, i - 2)]; + default -> rnd.nextLong() & 0xFFFFFFFFL; + }; + } + long[] expected = keys.clone(); + Arrays.sort(expected); + ForkJoinPool pool = new ForkJoinPool(3); + try { + long[][] r = ParallelRadixSort.sort(keys.clone(), null, 63, pool); + assertArrayEquals(expected, r[0]); + } finally { + pool.shutdown(); + } + } + + @Test + void radixSortTwoWordKeysMatchesReferenceSort() { + Random rnd = new Random(7); + int n = 100_000; + long[] lo = new long[n]; + long[] hi = new long[n]; + for (int i = 0; i < n; i++) { + lo[i] = rnd.nextLong(); + hi[i] = rnd.nextLong() & ((1L << 26) - 1); // 90-bit keys + if (i % 5 == 0 && i > 0) { // duplicates and hi-only ties + hi[i] = hi[i - 1]; + if (i % 10 == 0) lo[i] = lo[i - 1]; + } + } + long[][] rows = new long[n][2]; + for (int i = 0; i < n; i++) { + rows[i][0] = hi[i]; + rows[i][1] = lo[i]; + } + Arrays.sort(rows, Comparator.comparingLong(r -> r[0]).thenComparing(r -> r[1], Long::compareUnsigned)); + ForkJoinPool pool = new ForkJoinPool(3); + try { + long[][] r = ParallelRadixSort.sort(lo.clone(), hi.clone(), 90, pool); + for (int i = 0; i < n; i++) { + assertEquals(rows[i][0], r[1][i], "hi at " + i); + assertEquals(rows[i][1], r[0][i], "lo at " + i); + } + } finally { + pool.shutdown(); + } + } + + @Test + void packExtractRoundTripsAcrossTheWordBoundary() { + // 30+30+20+30 = 110 bits: k1 and k2 straddle or sit fully above the + // lo/hi boundary, covering every branch of extract(). + UltraBGIndex.Layout lay = new UltraBGIndex.Layout(30, 30, 20, 30); + Random rnd = new Random(11); + long[] lo = new long[1]; + long[] hi = new long[1]; + for (int t = 0; t < 10_000; t++) { + long v0 = rnd.nextLong() & ((1L << 30) - 1); + long v1 = rnd.nextLong() & ((1L << 30) - 1); + long v2 = rnd.nextLong() & ((1L << 20) - 1); + long v3 = rnd.nextLong() & ((1L << 30) - 1); + UltraBGIndex.pack(lo, hi, 0, v0, v1, v2, v3, lay); + assertEquals(v0, UltraBGIndex.extract(lo[0], hi[0], lay.off0(), lay.b0()), "k0"); + assertEquals(v1, UltraBGIndex.extract(lo[0], hi[0], lay.off1(), lay.b1()), "k1"); + assertEquals(v2, UltraBGIndex.extract(lo[0], hi[0], lay.off2(), lay.b2()), "k2"); + assertEquals(v3, UltraBGIndex.extract(lo[0], hi[0], lay.off3(), lay.b3()), "k3"); + } + // Single-word layouts must round-trip with hi == null. + UltraBGIndex.Layout small = new UltraBGIndex.Layout(10, 10, 5, 11); + UltraBGIndex.pack(lo, null, 0, 1023, 512, 31, 2047, small); + assertEquals(1023, UltraBGIndex.extract(lo[0], 0, small.off0(), small.b0())); + assertEquals(512, UltraBGIndex.extract(lo[0], 0, small.off1(), small.b1())); + assertEquals(31, UltraBGIndex.extract(lo[0], 0, small.off2(), small.b2())); + assertEquals(2047, UltraBGIndex.extract(lo[0], 0, small.off3(), small.b3())); + } + + /** + * The positional buffer's bytes must read back through the SEQUENTIAL + * bit-packed reader: that is exactly what the HDF5 readers do with these + * datasets, and it pins the big-endian byte-aligned layout. + */ + @Test + void packedBufferBytesReadBackThroughTheSequentialReader() { + Random rnd = new Random(3); + for (int width : new int[]{8, 16, 24, 40, 64}) { + int n = 257; + long[] values = new long[n]; + UltraPackedBuffer ultra = new UltraPackedBuffer("t", n, width); + for (int i = 0; i < n; i++) { + values[i] = (width == 64) ? rnd.nextLong() : (rnd.nextLong() & ((1L << width) - 1)); + ultra.set(i, values[i]); + } + BitPackedUnSignedLongBuffer reader = BitPackedUnSignedLongBuffer.readView( + new ByteBufferBytes(ByteBuffer.wrap(ultra.bytes())), n, width); + for (int i = 0; i < n; i++) { + assertEquals(values[i], reader.get(i), "width " + width + " entry " + i); + } + } + } + + @Test + void bitmapBytesReadBackThroughTheSequentialReader() { + Random rnd = new Random(9); + int n = 1003; // deliberately not a multiple of 8 or 64 + boolean[] bits = new boolean[n]; + UltraBitmap bitmap = new UltraBitmap("b", n); + for (int i = 0; i < n; i++) { + bits[i] = rnd.nextBoolean(); + if (bits[i]) { + bitmap.set(i); + } + } + assertEquals((n + 7) / 8, bitmap.toBytes().length); + BitPackedUnSignedLongBuffer reader = BitPackedUnSignedLongBuffer.readView( + new ByteBufferBytes(ByteBuffer.wrap(bitmap.toBytes())), n, 1); + for (int i = 0; i < n; i++) { + assertEquals(bits[i] ? 1 : 0, reader.get(i), "bit " + i); + } + } + + /** + * The rank maps ARE the dictionary: for every node, the O(1) map id must + * equal the storage dictionary's binary-search locate(). This is the + * invariant that lets the ultra writer never call locate() at all. + */ + @Test + void rankMapIdsMatchDictionaryLocate() throws Exception { + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s ex:v "1"^^xsd:int . + ex:s ex:v "1"^^xsd:long . + ex:s ex:v "1.0"^^xsd:double . + ex:s ex:v "01"^^xsd:integer . + ex:s ex:w "a" . + ex:s ex:w "a"@en . + ex:s ex:when "2020-01-02T00:00:00"^^xsd:dateTime . + _:x ex:p _:y . + _:y ex:p ex:s . + <> ex:self . + _:g { _:x ex:q "in bnode graph" . } + ex:g { ex:s ex:q _:y . } + """; + File src = dir.resolve("ids.trig").toFile(); + Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); + ForkJoinPool pool = new ForkJoinPool(2); + try { + UltraIngest ingest = new UltraIngest(); + ingest.setSource(src); + ingest.setDestination(dir.resolve("ids.unused.h5").toFile()); + ingest.setName(Params.DICTIONARY); + ingest.ingest(pool); + UltraDictionary dict = new UltraDictionary(ingest, pool); + Dictionary entities = (Dictionary) dict.entitiesDictionary(); + Dictionary predicates = (Dictionary) dict.predicatesDictionary(); + Dictionary literals = (Dictionary) dict.literalsDictionary(); + for (Node n : ingest.getEntities()) { + assertEquals(entities.locate(n), dict.locateGraph(n), "entity id of " + n); + assertEquals(entities.locate(n), dict.locateSubject(n), "entity id of " + n); + assertEquals(entities.locate(n), dict.locateObject(n), "object id of entity " + n); + } + for (Node n : ingest.getPredicates()) { + assertEquals(predicates.locate(n), dict.locatePredicate(n), "predicate id of " + n); + } + long maxEntityId = dict.getNumberOfGraphs(); + for (Node n : ingest.getLiterals()) { + assertEquals(literals.locate(n) + maxEntityId, dict.locateObject(n), "object id of literal " + n); + } + } finally { + pool.shutdown(); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java new file mode 100644 index 00000000..2f93e39a --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraWriterParityTest.java @@ -0,0 +1,308 @@ +package com.ebremer.beakgraph.hdf5.writers.ultra; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import io.jhdf.HdfFile; +import io.jhdf.api.Attribute; +import io.jhdf.api.Dataset; +import io.jhdf.api.Group; +import io.jhdf.api.Node; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end parity of the ultra writer against the sequential writer, read + * back through the UNMODIFIED jHDF-based readers: same graph lists, isomorphic + * per-graph content, same query answers through both indexes, and - for + * single-source builds, where the ultra ingest reuses the sequential blank + * node labels verbatim - structurally identical HDF5 metadata (same dataset + * tree, sizes, and attributes). Raw bytes are never compared: the VoID + * generator mints fresh random blank-node labels on every write, so bytes + * legitimately differ between ANY two writes (same caveat as the parallel and + * huge parity tests). Multi-source merges use per-document blank-node labels, + * so the merge test asserts semantic equivalence only. + */ +class UltraWriterParityTest { + + @TempDir + static Path dir; + + // ------------------------------------------------------------------ + // helpers (the established parity bar of ParallelWriterParityTest) + // ------------------------------------------------------------------ + + private static Path writeBoth(String name, String content, boolean spatial, boolean features) throws Exception { + File src = dir.resolve(name).toFile(); + Files.write(src.toPath(), content.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve(name + ".seq.h5").toFile(); + File ult = dir.resolve(name + ".ultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq) + .setSpatial(spatial).setFeatures(features).build().write(); + UltraHDF5Writer.Builder().setSource(src).setDestination(ult) + .setSpatial(spatial).setFeatures(features) + // deliberately odd core count: shakes out anything that silently + // assumes the default or an even chunk split + .setCores(3) + .build().write(); + return dir; + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static java.util.List select(org.apache.jena.query.Dataset ds, String query) { + java.util.List rows = new java.util.ArrayList<>(); + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + ResultSet rs = qe.execSelect(); + while (rs.hasNext()) { + QuerySolution qs = rs.next(); + StringBuilder sb = new StringBuilder(); + rs.getResultVars().forEach(v -> sb.append(v).append('=').append(qs.get(v)).append('|')); + rows.add(sb.toString()); + } + } + rows.sort(String::compareTo); + return rows; + } + + private static void assertStoresEquivalent(Path seqH5, Path ultraH5, String... probes) throws Exception { + try (BeakGraph a = new BeakGraph(new HDF5Reader(seqH5.toFile())); + BeakGraph b = new BeakGraph(new HDF5Reader(ultraH5.toFile()))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + + Model defA = graphModel(da, null); + Model defB = graphModel(db, null); + assertTrue(defA.isIsomorphicWith(defB), + "default graphs must be isomorphic (seq=" + defA.size() + ", ultra=" + defB.size() + ")"); + for (String g : graphNames(da)) { + if (!g.startsWith("http") && !g.startsWith("urn")) continue; + Model ga = graphModel(da, g); + Model gb = graphModel(db, g); + assertTrue(ga.isIsomorphicWith(gb), + "graph <" + g + "> must be isomorphic (seq=" + ga.size() + ", ultra=" + gb.size() + ")"); + } + for (String probe : probes) { + assertEquals(select(da, probe), select(db, probe), "probe results must match: " + probe); + } + } + } + + private static void assertSameStructure(Path seqH5, Path ultraH5) { + try (HdfFile seq = new HdfFile(seqH5); HdfFile ult = new HdfFile(ultraH5)) { + compareGroups("/", (Group) seq.getChild(".BG"), (Group) ult.getChild(".BG")); + } + } + + private static void compareGroups(String path, Group a, Group b) { + assertEquals(a != null, b != null, "group presence at " + path); + if (a == null) return; + compareAttributes(path, a, b); + Map ca = a.getChildren(); + Map cb = b.getChildren(); + assertEquals(new TreeSet<>(ca.keySet()), new TreeSet<>(cb.keySet()), "children of " + path); + for (String name : ca.keySet()) { + Node na = ca.get(name); + Node nb = cb.get(name); + assertEquals(na instanceof Group, nb instanceof Group, "node kind of " + path + name); + if (na instanceof Group ga) { + compareGroups(path + name + "/", ga, (Group) nb); + } else { + compareAttributes(path + name, na, nb); + assertEquals(((Dataset) na).getSize(), ((Dataset) nb).getSize(), + "dataset size of " + path + name); + } + } + } + + private static void compareAttributes(String path, Node a, Node b) { + Map attrsA = new HashMap<>(); + Map attrsB = new HashMap<>(); + for (Map.Entry e : a.getAttributes().entrySet()) { + attrsA.put(e.getKey(), e.getValue().getData()); + } + for (Map.Entry e : b.getAttributes().entrySet()) { + attrsB.put(e.getKey(), e.getValue().getData()); + } + assertEquals(attrsA, attrsB, "attributes of " + path); + } + + // ------------------------------------------------------------------ + // tests + // ------------------------------------------------------------------ + + @Test + void mixedTypesAndNamedGraphs() throws Exception { + String longStr = "y".repeat(150); + String trig = """ + @prefix ex: . + @prefix xsd: . + + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:p0 ex:o0 . + ex:s0 ex:name "Alice" . + ex:s0 ex:name "Alice"@en . + ex:s0 ex:name "Alicia"@fr-CA . + ex:s0 ex:count "42"^^xsd:int . + ex:s0 ex:count "042"^^xsd:int . + ex:s0 ex:count2 "-7"^^xsd:int . + ex:s0 ex:big "9223372036854775806"^^xsd:long . + ex:s0 ex:neg "-9007199254740993"^^xsd:long . + ex:s0 ex:f "1.5"^^xsd:float . + ex:s0 ex:d "-2.25E8"^^xsd:double . + ex:s0 ex:unbounded "123456789012345678901234567890"^^xsd:integer . + ex:s0 ex:flag true . + ex:s0 ex:when "2024-05-06T07:08:09Z"^^xsd:dateTime . + ex:s0 ex:longstr "%s" . + ex:s0 ex:uni "h\\u00e9llo \\u00fcrld" . + ex:s0 ex:ill "abc"^^xsd:int . + <> ex:self . + _:b1 ex:p0 _:b2 . + _:b2 ex:knows ex:s0 . + ex:g1 { + ex:s1 ex:p0 ex:o0 . + ex:s1 ex:num "10"^^xsd:int . + _:b1 ex:inGraph ex:g1 . + } + ex:g2 { ex:s0 ex:p0 ex:s1 . } + """.formatted(longStr); + writeBoth("mixed.trig", trig, false, false); + Path seq = dir.resolve("mixed.trig.seq.h5"); + Path ult = dir.resolve("mixed.trig.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?p ?o WHERE { ?p ?o }", + "SELECT ?s WHERE { ?s }", + "SELECT ?s ?o WHERE { GRAPH { ?s ?o } }", + "SELECT ?o WHERE { ?o }", + "SELECT ?g ?s WHERE { GRAPH ?g { ?s } }"); + assertSameStructure(seq, ult); + } + + @Test + void spatialAndFeaturesParity() throws Exception { + String trig = """ + @prefix ex: . + @prefix geo: . + + ex:geo1 geo:asWKT "POLYGON((0 0, 100 0, 100 100, 0 100, 0 0))"^^geo:wktLiteral . + ex:geo2 geo:asWKT "MULTIPOLYGON(((200 200, 300 200, 300 300, 200 200)),((600 600, 700 600, 700 700, 600 600)))"^^geo:wktLiteral . + ex:geo3 geo:asWKT "POINT(5000 6000)"^^geo:wktLiteral . + ex:geo4 geo:asWKT " POLYGON((10 10, 60 10, 60 60, 10 60, 10 10))"^^geo:wktLiteral . + ex:geo5 geo:asWKT "POLYGON((0 0, 1 0))"^^geo:wktLiteral . + ex:geo1 ex:label "region one" . + """; + writeBoth("spatial.trig", trig, true, true); + Path seq = dir.resolve("spatial.trig.seq.h5"); + Path ult = dir.resolve("spatial.trig.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } }", + "SELECT ?g WHERE { GRAPH ?g { ?p ?o } }"); + assertSameStructure(seq, ult); + } + + @Test + void stressManyQuadsMatchesSequentialWriter() throws Exception { + StringBuilder nq = new StringBuilder(1 << 22); + for (int i = 0; i < 30_000; i++) { + String s = ""; + String p = ""; + String o = switch (i % 4) { + case 0 -> ""; + case 1 -> "\"str" + (i % 1000) + "\""; + case 2 -> "\"" + (i % 1000 - 500) + "\"^^"; + default -> "\"" + ((i % 100) / 8.0) + "\"^^"; + }; + nq.append(s).append(' ').append(p).append(' ').append(o); + if (i % 3 != 0) { + nq.append(" '); + } + nq.append(" .\n"); + } + writeBoth("stress.nq", nq.toString(), false, false); + Path seq = dir.resolve("stress.nq.seq.h5"); + Path ult = dir.resolve("stress.nq.ultra.h5"); + assertStoresEquivalent(seq, ult, + "SELECT ?s ?o WHERE { ?s ?o }", + "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g", + "SELECT ?s WHERE { GRAPH { ?s \"str101\" } }"); + assertSameStructure(seq, ult); + } + + @Test + void singleCoreStillWorks() throws Exception { + String ttl = " .\n" + + " \"x\" .\n"; + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), ttl.getBytes(StandardCharsets.UTF_8)); + File seq = dir.resolve("one.ttl.seq.h5").toFile(); + File ult = dir.resolve("one.ttl.ultra.h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(seq).build().write(); + UltraHDF5Writer.Builder().setSource(src).setDestination(ult).setCores(1).build().write(); + assertStoresEquivalent(seq.toPath(), ult.toPath(), + "SELECT ?o WHERE { ?p ?o }"); + assertSameStructure(seq.toPath(), ult.toPath()); + } + + /** + * Merge parity: ultra's PARALLEL multi-document ingest against the + * sequential writer's one-after-another merge of the same tree. Blank node + * labels differ by design (per-document scoping vs a global counter), so + * the bar is semantic: same graphs, isomorphic content, same answers - + * including two documents that both say {@code _:b0} and must stay two + * distinct nodes. + */ + @Test + void mergeParityWithSequentialMerge() throws Exception { + Path src = Files.createDirectories(dir.resolve("mergesrc")); + Files.write(src.resolve("m1.ttl"), + " .\n_:b0 \"v1\" .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m2.nq"), + " .\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("m3.ttl"), + "_:b0 \"v2\" .\n .\n" + .getBytes(StandardCharsets.UTF_8)); + List inputs = List.of(src.resolve("m1.ttl").toFile(), src.resolve("m2.nq").toFile(), + src.resolve("m3.ttl").toFile()); + File seq = dir.resolve("merge.seq.h5").toFile(); + File ult = dir.resolve("merge.ultra.h5").toFile(); + HDF5Writer.Builder().setSources(inputs).setDestination(seq).build().write(); + UltraHDF5Writer.Builder().setSources(inputs).setDestination(ult).setCores(3).build().write(); + assertStoresEquivalent(seq.toPath(), ult.toPath(), + "SELECT ?o WHERE { ?o }", + "SELECT ?o WHERE { GRAPH { ?p ?o } }", + "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }"); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java b/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java index 5e1a234b..c7fc3bbe 100644 --- a/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java +++ b/src/test/java/com/ebremer/beakgraph/huge/ExternalSorterTest.java @@ -45,7 +45,7 @@ void multiLevelMergeSortsAndCleansUp() throws Exception { expected.sort(Comparator.naturalOrder()); assertEquals(n, sorter.size()); List actual = new ArrayList<>(n); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { while (s.hasNext()) { actual.add(s.next()); } @@ -64,7 +64,7 @@ void allInMemoryPathSkipsDisk() throws Exception { sorter.add(v); } List actual = new ArrayList<>(); - try (ExternalSorter.SortedStream s = sorter.sorted()) { + try (RecordSorter.SortedCursor s = sorter.sorted()) { s.forEachRemaining(actual::add); } assertEquals(List.of(1L, 3L, 3L, 5L, 9L), actual); From 65116dc77bc7a4dd3de5f30601292aa8eb03bfb1 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 15:18:41 -0400 Subject: [PATCH 07/17] add export feature --- docs/INSTRUCTIONS.md | 22 +++ .../beakgraph/cmdline/BeakGraphCLI.java | 172 ++++++++++++++++- .../cmdline/ExportFormatValidator.java | 32 ++++ .../ebremer/beakgraph/cmdline/Parameters.java | 13 ++ .../ebremer/beakgraph/cmdline/ExportTest.java | 177 ++++++++++++++++++ 5 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java create mode 100644 src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java diff --git a/docs/INSTRUCTIONS.md b/docs/INSTRUCTIONS.md index 867a4b55..d791ca63 100644 --- a/docs/INSTRUCTIONS.md +++ b/docs/INSTRUCTIONS.md @@ -63,6 +63,8 @@ java -jar BeakGraph.jar -endpoint out/example.h5 -port 8888 | `-features` | off | Also derive 2-D shape features (area, axes, …) for each geometry. Implies work under `-spatial`. | | `-workdir

    ` | dest dir | Spill workspace for `-method 1` and `-method 4`. Put this on your fastest disk. | | `-huge` | off | Legacy shorthand for `-method 1`. An explicit `-method` takes precedence. | +| `-export ` | — | **Export mode**: dump the BeakGraph(s) at `-src` back to RDF instead of converting. Formats: `NT`, `NQ`, `JSON-LD`, `TTL`, `TRIG` (case-insensitive). Output lands next to each `.h5` with the same name and the format's extension. See §5a. | +| `-compress` | off | gzip the `-export` output (adds `.gz` to the file name). | | `-status` | off | Progress bar (per-file mode) and end-of-run counters. | | `-endpoint ` | — | Serve the store as a SPARQL endpoint instead of converting. | | `-port ` | `8888` | HTTP port for `-endpoint`. | @@ -83,6 +85,26 @@ each also as gzip (`.ttl.gz`, …) or zip (`.ttl.zip`, …; the first non-direct Named graphs require a quad-capable syntax (TriG / N-Quads). Files with other extensions are counted and skipped. +## 5a. Exporting a BeakGraph back to RDF + +```bash +java -jar BeakGraph.jar -src data.h5 -export NQ # -> data.nq +java -jar BeakGraph.jar -src data.h5 -export TTL -compress # -> data.ttl.gz +java -jar BeakGraph.jar -src stores/ -export NT # every .h5 under stores/ +``` + +* `-src` may be one `.h5` file or a directory tree (every `.h5` under it exports). +* **Automatic quad upgrade**: if `TTL` or `NT` is requested but the store holds named + graphs beyond the default graph, the format silently upgrades to its quad form + (`TTL -> TRIG`, `NT -> NQ`) and the extension follows. +* BeakGraph's **internal metadata graphs** (`urn:x-beakgraph:void` statistics and the + `urn:x-beakgraph:Spatial` index) are derived build artifacts: they are excluded from + the export and from the named-graph decision, so a plain-triples store round-trips + to plain triples. +* NT/NQ/TTL/TRIG exports stream (any store size); JSON-LD has no streaming writer and + materializes the dataset in memory - use NQ/TRIG for bulk dumps. +* Writes are atomic (`.tmp` then rename); an existing export is replaced. + ## 6. Choosing a conversion method | `-method` | Engine | Memory | Parallelism | Use when | diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index fd247e98..d47d96ff 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -14,6 +14,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -55,9 +56,9 @@ public BeakGraphCLI(Parameters params) { this.fc = new FileCounter(); String os = System.getProperty("os.name").toLowerCase(); ProgressBarStyle style = os.contains("win") ? ProgressBarStyle.ASCII : ProgressBarStyle.COLORFUL_UNICODE_BLOCK; - // -merge is one big conversion, not a stream of per-file tasks; the - // per-file progress bar would only ever render empty. - if (params.status && !params.merge) { + // -merge is one big conversion (and -export its own flow), not a stream + // of per-file tasks; the per-file progress bar would only render empty. + if (params.status && !params.merge && params.export == null) { progressBar = new ProgressBarBuilder() .setTaskName("Processing RDF Source Files...") .setInitialMax(0) @@ -98,6 +99,14 @@ public static void main(String[] args) throws FileNotFoundException, IOException } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } else if (params.src != null && params.src.exists() && params.export != null) { + // Export mode: dump BeakGraph(s) back to RDF; no -dest + // involved (output lands beside each source .h5). + BeakGraphCLI bg = new BeakGraphCLI(params); + bg.export(); + if (bg.fc.getFailedConversionFileCount() > 0) { + System.exit(2); + } } else if (params.src != null && params.src.exists()) { if (params.dest == null) { // Without this guard every FileProcessor NPEs inside a @@ -246,6 +255,163 @@ public void merge() { } } + /** + * -export: dump the BeakGraph(s) at -src back to RDF. -src may be one .h5 + * file or a directory tree of them; each store exports to a sibling file + * with the same name and the format's extension (plus .gz with -compress). + * TTL/NT are upgraded to TRIG/NQ when a store holds named graphs beyond + * the default graph. BeakGraph-internal metadata graphs (the VoID + * statistics and spatial index, urn:x-beakgraph:*) are derived build + * artifacts and are excluded - both from the dump and from the "has named + * graphs" decision, so a plain-triples store round-trips to plain triples. + */ + public void export() { + final List inputs = new ArrayList<>(); + if (params.src.isDirectory()) { + try (var walk = Files.walk(params.src.toPath())) { + walk.filter(p -> p.toFile().isFile() + && p.getFileName().toString().toLowerCase().endsWith(".h5") + && p.toFile().length() > 0) + .sorted() + .forEach(p -> inputs.add(p.toFile())); + } catch (IOException ex) { + logger.error("Failed to scan source tree {}", params.src, ex); + } + } else { + inputs.add(params.src); + } + if (inputs.isEmpty()) { + System.err.println("No BeakGraph (.h5) files found under " + params.src); + return; + } + for (File h5 : inputs) { + fc.incrementRDFFileCount(); + try { + exportOne(h5); + } catch (Exception ex) { + fc.incrementFailedConversionFileCount(); + logger.error("Failed to export {}", h5, ex); + } + } + if (params.status) { + System.out.println(fc); + } + } + + private void exportOne(File h5) throws Exception { + String fmt = ExportFormatValidator.normalize(params.export); + try (com.ebremer.beakgraph.core.BeakGraph bg = new com.ebremer.beakgraph.core.BeakGraph( + new com.ebremer.beakgraph.hdf5.readers.HDF5Reader(h5))) { + org.apache.jena.sparql.core.DatasetGraph dsg = bg.getDataset().asDatasetGraph(); + boolean named = hasUserNamedGraphs(dsg); + if (named && "NT".equals(fmt)) { + logger.info("{} holds named graphs: exporting NQ instead of NT", h5.getName()); + fmt = "NQ"; + } else if (named && "TTL".equals(fmt)) { + logger.info("{} holds named graphs: exporting TRIG instead of TTL", h5.getName()); + fmt = "TRIG"; + } + String ext = switch (fmt) { + case "NT" -> "nt"; + case "NQ" -> "nq"; + case "TTL" -> "ttl"; + case "TRIG" -> "trig"; + default -> "jsonld"; + }; + String base = h5.getName(); + if (base.toLowerCase().endsWith(".h5")) { + base = base.substring(0, base.length() - 3); + } + Path out = h5.toPath().resolveSibling(base + "." + ext + (params.compress ? ".gz" : "")); + Path tmp = out.resolveSibling(out.getFileName() + ".tmp"); + logger.info("Exporting {} -> {} ({})", h5.getName(), out.getFileName(), fmt); + try (OutputStream os = openExportStream(tmp)) { + writeExport(os, dsg, fmt); + } catch (Exception ex) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + throw ex; + } + try { + Files.move(tmp, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + logger.info("Export complete: {}", out); + } + } + + private OutputStream openExportStream(Path tmp) throws IOException { + OutputStream os = new java.io.BufferedOutputStream(Files.newOutputStream(tmp), 1 << 17); + return params.compress ? new java.util.zip.GZIPOutputStream(os, 1 << 16) : os; + } + + /** A graph the USER put in the store (not BeakGraph's own metadata graphs). */ + private static boolean isUserGraph(org.apache.jena.graph.Node g) { + return !Params.BGVOID.equals(g) && !Params.SPATIAL.equals(g); + } + + private static boolean hasUserNamedGraphs(org.apache.jena.sparql.core.DatasetGraph dsg) { + var it = dsg.listGraphNodes(); + while (it.hasNext()) { + if (isUserGraph(it.next())) { + return true; + } + } + return false; + } + + private static void writeExport(OutputStream os, org.apache.jena.sparql.core.DatasetGraph dsg, String fmt) + throws IOException { + switch (fmt) { + case "NT", "TTL" -> { + // Triple export: by this point the store has no user named + // graphs, so the default graph IS the data. + org.apache.jena.riot.system.StreamRDF stream = org.apache.jena.riot.system.StreamRDFWriter + .getWriterStream(os, "NT".equals(fmt) + ? org.apache.jena.riot.RDFFormat.NTRIPLES + : org.apache.jena.riot.RDFFormat.TURTLE_BLOCKS); + stream.start(); + var it = dsg.getDefaultGraph().find(); + while (it.hasNext()) { + stream.triple(it.next()); + } + stream.finish(); + } + case "NQ", "TRIG" -> { + org.apache.jena.riot.system.StreamRDF stream = org.apache.jena.riot.system.StreamRDFWriter + .getWriterStream(os, "NQ".equals(fmt) + ? org.apache.jena.riot.RDFFormat.NQUADS + : org.apache.jena.riot.RDFFormat.TRIG_BLOCKS); + stream.start(); + var it = dsg.find(); + while (it.hasNext()) { + var q = it.next(); + if (isUserGraph(q.getGraph())) { + stream.quad(q); + } + } + stream.finish(); + } + default -> { + // JSON-LD has no streaming writer: materialize the filtered + // dataset. Fine for JSON-LD-sized data; use NQ/TRIG for bulk. + org.apache.jena.sparql.core.DatasetGraph copy = + org.apache.jena.sparql.core.DatasetGraphFactory.create(); + var it = dsg.find(); + while (it.hasNext()) { + var q = it.next(); + if (isUserGraph(q.getGraph())) { + copy.add(q); + } + } + org.apache.jena.riot.RDFDataMgr.write(os, copy, org.apache.jena.riot.Lang.JSONLD); + } + } + } + /** * The conversion engine for this run: {@code -method} (0 = in-memory, * 1 = disk, 2 = parallel, 3 = ultra), with the legacy {@code -huge} flag diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java b/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java new file mode 100644 index 00000000..7ce7aca0 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/cmdline/ExportFormatValidator.java @@ -0,0 +1,32 @@ +package com.ebremer.beakgraph.cmdline; + +import com.beust.jcommander.IParameterValidator; +import com.beust.jcommander.ParameterException; + +/** + * Validates {@code -export}: NT, NQ, JSON-LD (or JSONLD), TTL, or TRIG, + * case-insensitively. + */ +public class ExportFormatValidator implements IParameterValidator { + + /** Canonical form (NT/NQ/JSONLD/TTL/TRIG), or null if unrecognized. */ + static String normalize(String value) { + if (value == null) return null; + return switch (value.trim().toUpperCase().replace("-", "")) { + case "NT" -> "NT"; + case "NQ" -> "NQ"; + case "JSONLD" -> "JSONLD"; + case "TTL" -> "TTL"; + case "TRIG" -> "TRIG"; + default -> null; + }; + } + + @Override + public void validate(String name, String value) throws ParameterException { + if (normalize(value) == null) { + throw new ParameterException("Parameter " + name + + " must be NT, NQ, JSON-LD, TTL, or TRIG; found \"" + value + "\""); + } + } +} diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index 42be415d..f2f2593a 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -72,6 +72,19 @@ public class Parameters { + "-threads N, N conversions run at once, each capped at -cores)") public int cores = 4; + @Parameter(names = "-export", validateWith = ExportFormatValidator.class, + description = "Dump the BeakGraph(s) at -src back to RDF instead of converting: " + + "NT, NQ, JSON-LD, TTL, or TRIG. Output lands next to each .h5 with the " + + "same name and the format's extension. If TTL or NT is chosen but the " + + "store holds named graphs beyond the default graph, the format is " + + "upgraded to its quad form (TTL->TRIG, NT->NQ). BeakGraph-internal " + + "metadata graphs (VoID/spatial index) are not exported") + public String export = null; + + @Parameter(names = {"-compress"}, converter = BooleanConverter.class, + description = "gzip the -export output (adds .gz to the file name)") + public boolean compress = false; + @Parameter(names = {"-version","-v"}, converter = BooleanConverter.class) public boolean version = false; diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java new file mode 100644 index 00000000..0b3b4b6f --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/ExportTest.java @@ -0,0 +1,177 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.GZIPInputStream; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFDataMgr; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * -export: dumping a BeakGraph back to RDF. Round-trips must reproduce the + * source data (isomorphic; BeakGraph's internal VoID/spatial metadata graphs + * are excluded), TTL/NT must auto-upgrade to TRIG/NQ when user named graphs + * exist, and -compress must gzip with the extra .gz extension. + */ +class ExportTest { + + @TempDir + Path dir; + + private static final String TRIPLES = + " .\n" + + " \"Alice\" .\n" + + " \"5\"^^ .\n"; + + private static final String QUADS = TRIPLES + + " .\n"; + + private File buildStore(String name, String nquads) throws Exception { + File src = dir.resolve(name + ".nq").toFile(); + Files.write(src.toPath(), nquads.getBytes(StandardCharsets.UTF_8)); + File h5 = dir.resolve(name + ".h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(h5).build().write(); + return h5; + } + + private void runExport(File src, String format, boolean compress) { + Parameters p = new Parameters(); + p.src = src; + p.export = format; + p.compress = compress; + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.export(); + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), + "export of " + src + " as " + format + " must succeed"); + } + + private Dataset parse(Path file, Lang lang, boolean gzipped) throws Exception { + Dataset ds = DatasetFactory.create(); + try (InputStream in = gzipped + ? new GZIPInputStream(Files.newInputStream(file)) + : Files.newInputStream(file)) { + RDFDataMgr.read(ds, in, lang); + } + return ds; + } + + private static Model expectedTriples() { + Model m = ModelFactory.createDefaultModel(); + RDFDataMgr.read(m, new java.io.ByteArrayInputStream(TRIPLES.getBytes(StandardCharsets.UTF_8)), Lang.NTRIPLES); + return m; + } + + @Test + void ntExportRoundTripsAndExcludesInternalGraphs() throws Exception { + File h5 = buildStore("plain", TRIPLES); + runExport(h5, "NT", false); + Path out = dir.resolve("plain.nt"); + assertTrue(Files.exists(out), "plain.nt must be created next to plain.h5"); + Dataset ds = parse(out, Lang.NTRIPLES, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples()), + "exported triples must round-trip the source data"); + assertFalse(ds.asDatasetGraph().listGraphNodes().hasNext(), + "VoID/spatial metadata graphs must not leak into the export"); + } + + @Test + void ntUpgradesToNqWhenNamedGraphsExist() throws Exception { + File h5 = buildStore("upg", QUADS); + runExport(h5, "NT", false); + assertFalse(Files.exists(dir.resolve("upg.nt")), "NT must not be written for a quad store"); + Path out = dir.resolve("upg.nq"); + assertTrue(Files.exists(out), "the export must upgrade NT -> NQ"); + Dataset ds = parse(out, Lang.NQUADS, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertTrue(ds.asDatasetGraph().containsGraph( + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/g1")), + "the named graph must survive the round trip"); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void ttlUpgradesToTrigWhenNamedGraphsExist() throws Exception { + File h5 = buildStore("upgt", QUADS); + runExport(h5, "TTL", false); + assertFalse(Files.exists(dir.resolve("upgt.ttl"))); + Path out = dir.resolve("upgt.trig"); + assertTrue(Files.exists(out), "the export must upgrade TTL -> TRIG"); + Dataset ds = parse(out, Lang.TRIG, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void ttlStaysTtlForDefaultOnlyStores() throws Exception { + File h5 = buildStore("ttlonly", TRIPLES); + runExport(h5, "TTL", false); + Path out = dir.resolve("ttlonly.ttl"); + assertTrue(Files.exists(out)); + Dataset ds = parse(out, Lang.TURTLE, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + } + + @Test + void compressedExportGetsGzExtensionAndGzipContent() throws Exception { + File h5 = buildStore("gz", QUADS); + runExport(h5, "NQ", true); + Path out = dir.resolve("gz.nq.gz"); + assertTrue(Files.exists(out), "-compress must add .gz"); + Dataset ds = parse(out, Lang.NQUADS, true); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void jsonLdExportRoundTrips() throws Exception { + File h5 = buildStore("jld", QUADS); + runExport(h5, "JSON-LD", false); + Path out = dir.resolve("jld.jsonld"); + assertTrue(Files.exists(out)); + Dataset ds = parse(out, Lang.JSONLD, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + assertEquals(1, ds.getNamedModel("http://ex.org/g1").size()); + } + + @Test + void directorySourceExportsEveryStore() throws Exception { + Path sub = Files.createDirectories(dir.resolve("many")); + for (String name : new String[]{"one", "two"}) { + File src = sub.resolve(name + ".nt").toFile(); + Files.write(src.toPath(), TRIPLES.getBytes(StandardCharsets.UTF_8)); + HDF5Writer.Builder().setSource(src).setDestination(sub.resolve(name + ".h5").toFile()) + .build().write(); + } + runExport(sub.toFile(), "NT", false); + assertTrue(Files.exists(sub.resolve("one.nt"))); + assertTrue(Files.exists(sub.resolve("two.nt"))); + Dataset ds = parse(sub.resolve("two.nt"), Lang.NTRIPLES, false); + assertTrue(ds.getDefaultModel().isIsomorphicWith(expectedTriples())); + } + + @Test + void exportOptionParsesAndRejectsUnknownFormats() { + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x.h5", "-export", "json-ld", "-compress"); + assertEquals("json-ld", p.export); + assertTrue(p.compress); + assertEquals("JSONLD", ExportFormatValidator.normalize(p.export)); + org.junit.jupiter.api.Assertions.assertThrows(com.beust.jcommander.ParameterException.class, + () -> com.beust.jcommander.JCommander.newBuilder().addObject(new Parameters()).build() + .parse("-src", "x.h5", "-export", "RDFXML"), + "unsupported export formats must be rejected"); + } +} From 298f0c9b3b7243486fe2eb649e4ce6ed5b828df3 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 18:06:34 -0400 Subject: [PATCH 08/17] add parallel disk sorter --- docs/BeakGraph-HDF5-Architecture.pptx | Bin 44319 -> 44375 bytes docs/INSTRUCTIONS.md | 17 +- .../beakgraph/cmdline/BeakGraphCLI.java | 46 +++++ .../beakgraph/cmdline/MethodValidator.java | 7 +- .../ebremer/beakgraph/cmdline/Parameters.java | 23 ++- .../beakgraph/core/AbstractGraphBuilder.java | 15 ++ .../com/ebremer/beakgraph/core/VoidMode.java | 22 +++ .../beakgraph/core/fuseki/BGVoIDSD.java | 155 ++++++++++----- .../core/fuseki/DistinctNodeCounter.java | 99 ++++++++++ .../beakgraph/core/lib/HyperLogLog.java | 63 ++++++ .../beakgraph/hdf5/writers/HDF5Writer.java | 1 + .../PositionalDictionaryWriterBuilder.java | 52 +++-- .../hugeUltra/HugeUltraHDF5Writer.java | 3 +- .../hugeUltra/UltraSorterProvider.java | 4 +- .../writers/parallel/ParallelHDF5Writer.java | 1 + .../hdf5/writers/plaid/PlaidHDF5Writer.java | 169 ++++++++++++++++ .../hdf5/writers/plaid/PlaidIngest.java | 181 ++++++++++++++++++ .../hdf5/writers/plaid/package-info.java | 26 +++ .../hdf5/writers/ultra/UltraHDF5Writer.java | 1 + .../hdf5/writers/ultra/UltraIngest.java | 46 +++-- .../beakgraph/huge/HugeBuildPipeline.java | 112 +++++++++-- .../beakgraph/huge/HugeHDF5Writer.java | 2 +- .../beakgraph/huge/SpatialAugmenter.java | 8 +- .../beakgraph/DistinctCorrectnessTest.java | 2 +- .../HighSeverityFixesRoundTripTest.java | 2 +- .../beakgraph/QuadFormatIngestionTest.java | 6 +- .../ebremer/beakgraph/ReorderQueryTest.java | 2 +- .../beakgraph/UnionGraphAndDetachTest.java | 2 +- .../beakgraph/VariableGraphScanTest.java | 2 +- .../cmdline/BeakGraphCLIConversionTest.java | 12 +- .../cmdline/MergeAndFormatsTest.java | 21 ++ .../beakgraph/cmdline/VoidModeTest.java | 100 ++++++++++ .../ebremer/beakgraph/core/VoidStatsTest.java | 2 +- .../beakgraph/core/fuseki/VoidSketchTest.java | 101 ++++++++++ .../writers/plaid/PlaidWriterParityTest.java | 151 +++++++++++++++ 35 files changed, 1322 insertions(+), 134 deletions(-) create mode 100644 src/main/java/com/ebremer/beakgraph/core/VoidMode.java create mode 100644 src/main/java/com/ebremer/beakgraph/core/fuseki/DistinctNodeCounter.java create mode 100644 src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidIngest.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/package-info.java create mode 100644 src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java diff --git a/docs/BeakGraph-HDF5-Architecture.pptx b/docs/BeakGraph-HDF5-Architecture.pptx index bdfcb374ec5dc763704b5541a9dc94aa4d40a207..8f3b3f2af8fd85d337b3eb189651be25cbe02bdd 100644 GIT binary patch delta 2756 zcmYk8dpuMBAIGJ>i3&ZE<{RPXoNE2{&ugFO`}KaE_c{N(KU6m2EgK=?>coeXg27;dFy)j6 z5orWMIfbQ)*bWEPnU4YKB2oe3n+0w_S}T+f@jaW)K-wnq6w+k08)~Bu7?c_S+iRtD z1VA0ectK^X;u;@V-h0ppz?29B!`mN#Wkb~yND%ir8UnAjcY~-{vl?=OC{7nbPCH6? zE)^_<=CO`Ebe}E^+;M~Qj7^pK|Kw#@K~7)m1H8OZI|wmxs=v`X_{3MczwolSPTgqb zl=uGl{#!X0kucaU3BXo$8|$kxiD#*EgF<56yTOpsy$7Kebn+&EM^+b}0DdQ6B1;pX zRtN)^1E9gI4`HCeK&)RW3}gjC*zK4c4_^y~as$Jpc!(PLYnnCx*+n!82Ah@!$Z0Y_ z)@ebahk}VX@k_?VfvU7YZ*u8nvOtwfP^7s@^YRaXuxZcwn-P<^x zlgh2?*Kw>ONcKzS9%5|i+Ckl~CskkXSLJO>PKwr%uJh4+dYAI7{en`%6CE}4=R#kI z|K)C>*Uj``y5t7Bt#o(g%eml1=OdcPc{DV)r;5-Y;w4 z?G*R9a{gX}_fod9ZTjsm_rlGDBegWW*c8`-c*4hnQlieCFMyA(Hz-kdM=O#ogDKPO zk2n)1!L*qf6iYX2PFEI2;(F5hLiG%YPrXUqX|ss8ANqRC&YM@vAO1(YFC^*gV%w?X z&B1dDe$%GDIjr95ag?^=(wzjYiI%w8CHc&bR%vXc%Fbr>vHPnA0u?wG8F7WoFJt8J z+|FYuEG;ugOV)-K)PBW04&N5c8amlj%m_HVplerTyT|iv_S5>zBPUYaL={n)MEa*k zE$|a5+8y6t$aC6{=ilXbS9<(;9#N+nh1$HUVtmYQ*5eGj=7-)Yrs6xEH5$(03u_+= zZ`xF{{CxME&Y9)YYGH}(Ypr{fF`WTP(*`=WZ)IxCrbxSIs(gi<{K`Y_2nZoW+}j(; z2?KfBxCiQ!^OG6;?dLqDXr1b;b)xCHXA{wRN1svjRbHFJWAB`Eq54t%+BIJ0PLSN9 zLitI88WPOtBc6&wO?O0-p3v2nC5{+(uw8z2L#ceI%r8ytQIuj$}ZuwNIxzUaCIg*jslw`2`(L86c`T+?Iy zaoiIXl)ZA^ttXe}?T8Ot2Z|nGRpZKFpF7a9Fe{dLiuX1n6!qUP@s+L z4QUc`+bMoOsjbQEU5*daz993*zMof9r`vVD%=}zFZK@d&{fONiYLvp2aS=#_XBb$v z4rt^%Kf;LD`%I*do>cF-?p^A!W&G#$8&5&l27ffTl{c%Y|z-|l#={dB_YI^J!sd$ z4Q%FA8nKtysd(muNjjJIG3Xk5Jz%9|y^FNmhr(6Z>0vvElV6#o;Eyk{{0}<@vRZBv zv}A0OZ%^hLG&-cVixcngu~;)$8Nq%@!_h;z+l)&uDa~AaXH_?f1v-biZhh7#Pe*Sv zBcSv9FK|Qnkjdt2^@W>YE{@?EFYSVIkFuV4d|Np;=9}l*nXvGW;2W#t)k(9vA4Tf~ zeAu`}TO;McOYcMhR!mEJ(^gJ$oZUC|*bD11H`gXsZ6e2ZW?2xslUOm^`JS2WHRA6H z2@m?Z?c_i#A1B&}Ya}0m3%^^skN$9W%5V1({nr*~(;p_)Bc&~B|BkpAOch=yENVZP z_qIP;G|9#kEg9=N)IaMfD`+4c!)v&gOUhUtCJ)&LroM0eu-{=+;o_yF+_eR|$?|!@ z>z@04Z6`>dY#Rm`#D-P#(0pTETO~<&P?8gtL3UFSU zIqOq?j)KX&v$~?nu-8(Ffy7|mqs&E7qX)>i$9yiL$1{j4+!uj?SN8!K3YB^AjuN4# zw^pr>>HJ5KxKE=xy>)n#a|naNpvGul?A9ZXE|ea-|5{JYN|kE9uI3Pg<8nOHFXmn6 zN=v*XR9OAH<^NYYw1bMGp{)`YbA`86qzS*a3TxzjTJgq?!;67ig@uf}3GFy=MGYrF zU6qMbJk(5rP*R%T@1Sqsx_fiA0XnATE`mp5-XL`JrPzQ=u#jQ`NO1)sKrqGc=3MFqDS=sC5`F=$`I8T6vGmz)?11Js3lM?=P%hq zgGdUE=lS}J=+SwkkPdkU{}TIBh+HZC?SY=EUB>IkEraU)e~EQDL}JT%p6Opio$-5C z43N!$-mI75368B%VF$`rCI(2Z&EKn3J49r!DfOMq_1J46wtJVHk4Q+bkU)1n;HxPK?VY9)Re%n zgIgmA%^O!vV>k?^vY~%|@$e>~q(&d|;7iaqOZj22Nii5~%OAsr#>1Jwml|zUA_av1 E1D4CblmGw# delta 2769 zcmZ9Oc{o)2AIHa7vTuzg4PhiiLRl(8ls$WKZQ*7~(KV6FG-}XTvUDQ*HpVuFWKbcg z#xja)-=>iyCRuKx-$Us-N2cG?dFFZE&v|{`@6Y#h=AX}ZWE@^F4(GSEWamJ@U@&f& zQ%Ehp7#!{tQip;cU<1{Py%f?f9D5-yvMUGDYMxApoAF(Rw28kH(pW(|uB{v}iWmgr z)nW&CfjWV7g36jQo9tlO-1#WrC*KX0j-dQFK=hC45#W0vFEDYS4aE6Mm5>v}H&h{~ zrCK4&dA67rIH(CZ7d1^;I6wz-YU|2!?(|I3hmecGSyn%W5rjy_sGsNucw*n_C03tY zBJW+RHbJM5_inBOl)DieFqnoYV2F|jmWiBoYgRaxv&IhU6=mNI_9B=&L*Hq64gNrA>7zdDIH<#QrYNd+X)~Qr*)f$r^oA=Ot0+(RaZT)6t#%H-F}Rz!owdy+ZE92 zW?+8q$#e>P^0dN0TqG=y(lmau)t$V!n&U#dV3B^&p0ZY{t`cczs)SO!JIr{!Orejm zU&iCP@84=z`{20LqPNMsHavI&?koPp+?80X;es7lLF<+(_o@4y=B%dL6JNe1Su~ra z9Y(>1QMJlvuG?X>jm ze0Qq5ECo)wu~7aIWrRlvOk!Qa5ey64Qi>iylk>QLIzqs@s|N`1%*jO?d6&hW_%nBQ zoN25>!W?NNp}w67T|^imxMMPR6YJJq%3?j`4jp*N=azFxYj%*C`?w%VuJ~Wr%-mOU z{{`x}&cmLo4;B22?F9lPPtB+EU%x;{XeH#0D|J0L8Y?xvvm2{oE^!4Y@ch)|9_ zrm+~SbxLe%-79gCp<=~=R|rk7hTI%sJBGu@I4m#8tySm>sBBuE>yqJ!c$MBvbo*Szm}o|WMc<5!0>6gYP`@#+ z{U4Q*oa%do_W7yK(D_hO&NR5O; zYHT7?=e>sn@6b?9M;mW0o2bS=<;to*dyqL5URiQ2#*JV&H)VItQn}@}?YW?Ea)$Hw zGxLL|oDe$CLY<pgmWUZ8n%hPp@9PL@BrlH$ianv|*R zr_gL#=MMj5OeeqAxc|~sx~mIcL;XR)j>-J}raK3?5gbs58uqXuX;8vL81t)MN7@LM z3%9yVUa_$yidE0G3_p+3N9H!cX3t*vE5G}1W`<2;=cVf(5cqXdpj(>(Ga(I zCX`bVquD0vuB~7$+^}qN?nJ0)9EabUi|>ljlRYiNIoL>_y;0)^gHIhcga@TYjUKH{ zJ+LDUO6^@ZMf6y?WV#{zKS^RccEn(=RnY`2cCWtJLZPo!xzKUkP5D`(8#g-&O3MSoqmn0&NOw5MwvgHI(tP` z4@%re^YJ=XJ&b9U;V`tsI_K;A%m2QZO#j#+^rJblQFl>wH@1gSz`uW1VE}QM zPnh2}`;sRbgr=QPG_V*n?t_Z6OyuuWCIGY_mVqOxjv9<=F%DME!-8(PifX?v|l*FH%XK=3#o*xJpcX+R=2!xcJl&#!=B; zjG6wX#Q!hmtrfBYZj|Eio2{-PNSf)Tu!PHs^x21mpk7&#ogl(g3mJqg_dUtibOtC zbKaKpe5f{(55*-5tU-%k!Ff>H3T!|TDRcoPs1T~vmLP$VLa47;5sO3@LFDN#qFf9S zzhaiBf13ysSR|4Fc^>~F3MCNnF8S$!=HMf;5@AHBPWeUll|sb5^yjQdpsN)6K<+X} zaBPnZl%=4VX&xpKgr2sOik^s31 fI<%<@3CE;wMNz=FZNf`NvV{T%$SPcxxnS%+gD}WX diff --git a/docs/INSTRUCTIONS.md b/docs/INSTRUCTIONS.md index d791ca63..b8da2b43 100644 --- a/docs/INSTRUCTIONS.md +++ b/docs/INSTRUCTIONS.md @@ -59,6 +59,8 @@ java -jar BeakGraph.jar -endpoint out/example.h5 -port 8888 | `-cores ` | `4` | Threads used **inside** one conversion by `-method 2`, `3`, and `4`. | | `-threads ` | `1` | Number of conversions run **at once** (per-file mode). Each conversion gets its own `-cores` budget — total CPU ≈ `threads × cores`. | | `-merge` | off | Merge **all** sources under `-src` into ONE store at `-dest` (if `-dest` is an existing directory, writes `/merged.h5`). Blank nodes stay distinct per source document. Works with every `-method`. | +| `-void` | off | Generate the VoID/SD statistics graph (`urn:x-beakgraph:void`) with **exact** in-memory counting (RAM grows with distinct terms). Mutually exclusive with `-voidsketch`. | +| `-voidsketch` | off | Generate the statistics graph with **bounded memory**: exact up to 65,536 distinct nodes per counter, then HyperLogLog estimates (~0.8% error, deterministic). Recommended for `-method 1/4/5`. Mutually exclusive with `-void`. | | `-spatial` | off | Build the Hilbert-curve spatial index for `geo:wktLiteral` geometry (adds the `urn:x-beakgraph:Spatial` graph). | | `-features` | off | Also derive 2-D shape features (area, axes, …) for each geometry. Implies work under `-spatial`. | | `-workdir ` | dest dir | Spill workspace for `-method 1` and `-method 4`. Put this on your fastest disk. | @@ -114,19 +116,21 @@ java -jar BeakGraph.jar -src stores/ -export NT # every .h5 unde | `2` | Parallel in-memory | Whole input on heap | `-cores` | Medium files, faster than 0. | | `3` | **Ultra** in-memory | Whole input on heap | `-cores` | Fastest option **for data that fits in RAM**: parallel parse, O(1) id maps, radix-sorted packed keys, parallel index emission. | | `4` | **hugeUltra** disk-based | **Bounded** by spill batches | `-cores` | Multi-billion-quad builds: the `-method 1` pipeline on parallel machinery — background radix-sorted spills, packed primitive keys, grouped term runs, concurrent stages. Native HDF5 required. | +| `5` | **plaid** disk-based | **Bounded** by spill batches | `-cores` | Method 4 **plus parallel multi-file ingest**: up to `-cores` source documents parse concurrently. The fastest option for `-merge` over many files. Native HDF5 required. | -Rules of thumb: fits comfortably in heap → `-method 3`. Doesn't fit → `-method 4`. +Rules of thumb: fits comfortably in heap → `-method 3`. Doesn't fit and merging many files → +`-method 5`; doesn't fit, single giant file → `-method 4`. Methods 0/1/2 remain for compatibility, minimal-dependency, and low-memory-machine cases. For per-file batch conversion of MANY small files, prefer `-threads N` (parallel conversions) over large `-cores`; for one big file, all the parallelism comes from `-cores`. -## 7. Very large builds (`-method 4`, 10⁹–10¹¹ quads) +## 7. Very large builds (`-method 4`/`5`, 10⁹–10¹¹ quads) ```bash java -Xmx32g -jar BeakGraph.jar \ -src shards/ -dest giant.h5 -merge \ - -method 4 -cores 32 -workdir /nvme/scratch -status + -method 5 -cores 32 -workdir /nvme/scratch -status ``` * **Workspace**: budget roughly 3–5× the uncompressed source on `-workdir`; use NVMe. @@ -136,6 +140,11 @@ java -Xmx32g -jar BeakGraph.jar \ `setIdSpillBatch` / `setTermSpillBatch` on `HugeUltraHDF5Writer.Builder` (defaults: 4M id records / 512K term records per run; each id record costs 16 bytes × 2 buffers while sorting). * **Shard your input**: `-merge` over many files is the natural way to feed 100B quads. +* **Statistics are opt-in**: no VoID/SD graph is written unless you pass `-void` (exact, + in-memory) or `-voidsketch` (bounded memory via HyperLogLog). Readers use the VoID + statistics for join reordering when present and fall back to a fixed heuristic when + absent - for big builds, `-voidsketch` buys statistics-driven query optimization at + ~64 KiB per counter instead of holding the dictionary on the heap. * Blank nodes are scoped per source document (labels are not preserved in the output format; readers regenerate labels from dictionary ranks). @@ -154,7 +163,7 @@ new / *Writer*.Builder() Classes: `hdf5.writers.HDF5Writer` (0) · `huge.HugeHDF5Writer` (1) · `hdf5.writers.parallel.ParallelHDF5Writer` (2) · `hdf5.writers.ultra.UltraHDF5Writer` (3) · -`hdf5.writers.hugeUltra.HugeUltraHDF5Writer` (4). +`hdf5.writers.hugeUltra.HugeUltraHDF5Writer` (4) · `hdf5.writers.plaid.PlaidHDF5Writer` (5). Reading: diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java index d47d96ff..d3a6f361 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/BeakGraphCLI.java @@ -8,6 +8,7 @@ import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; import com.ebremer.beakgraph.hdf5.writers.parallel.ParallelHDF5Writer; import com.ebremer.beakgraph.hdf5.writers.hugeUltra.HugeUltraHDF5Writer; +import com.ebremer.beakgraph.hdf5.writers.plaid.PlaidHDF5Writer; import com.ebremer.beakgraph.hdf5.writers.ultra.UltraHDF5Writer; import com.ebremer.beakgraph.huge.HugeHDF5Writer; import com.ebremer.beakgraph.utils.RdfSources; @@ -52,6 +53,11 @@ public FileCounter getFileCounter() { public BeakGraphCLI(Parameters params) { JenaSystem.init(); + if (params.voidExact && params.voidSketch) { + throw new IllegalArgumentException( + "-void and -voidsketch are mutually exclusive: pick exact in-memory statistics " + + "(-void) or bounded-memory HyperLogLog statistics (-voidsketch)"); + } this.params = params; this.fc = new FileCounter(); String os = System.getProperty("os.name").toLowerCase(); @@ -75,6 +81,12 @@ public static void main(String[] args) throws FileNotFoundException, IOException if (args.length != 0) { try { jc.parse(args); + if (params.voidExact && params.voidSketch) { + System.err.println("Error: -void and -voidsketch are mutually exclusive. " + + "Use -void for exact in-memory statistics or -voidsketch for the " + + "bounded-memory HyperLogLog version."); + System.exit(1); + } if (params.version) { // Must be handled on the SUCCESS path: version was previously // printed only inside the ParameterException catch, so a plain @@ -412,6 +424,17 @@ private static void writeExport(OutputStream os, org.apache.jena.sparql.core.Dat } } + /** The VoID statistics mode for this run: NONE unless -void or -voidsketch was given. */ + private com.ebremer.beakgraph.core.VoidMode voidMode() { + if (params.voidExact) { + return com.ebremer.beakgraph.core.VoidMode.EXACT; + } + if (params.voidSketch) { + return com.ebremer.beakgraph.core.VoidMode.SKETCH; + } + return com.ebremer.beakgraph.core.VoidMode.NONE; + } + /** * The conversion engine for this run: {@code -method} (0 = in-memory, * 1 = disk, 2 = parallel, 3 = ultra), with the legacy {@code -huge} flag @@ -436,6 +459,7 @@ private BeakGraphWriter newWriter(File source, List sources, File dest) th // own workspace. HugeHDF5Writer.Builder builder = HugeHDF5Writer.Builder() .setDestination(dest) + .setVoidMode(voidMode()) .setSpatial(params.spatial) .setFeatures(params.features); if (source != null) builder.setSource(source); @@ -450,6 +474,7 @@ private BeakGraphWriter newWriter(File source, List sources, File dest) th // Multi-threaded in-memory build on a pool of -cores threads. ParallelHDF5Writer.Builder builder = ParallelHDF5Writer.Builder() .setDestination(dest) + .setVoidMode(voidMode()) .setSpatial(params.spatial) .setFeatures(params.features) .setCores(params.cores); @@ -462,6 +487,7 @@ private BeakGraphWriter newWriter(File source, List sources, File dest) th // indexes, parallel emission - also capped at -cores threads. UltraHDF5Writer.Builder builder = UltraHDF5Writer.Builder() .setDestination(dest) + .setVoidMode(voidMode()) .setSpatial(params.spatial) .setFeatures(params.features) .setCores(params.cores); @@ -475,6 +501,25 @@ private BeakGraphWriter newWriter(File source, List sources, File dest) th // for multi-billion-quad builds. Needs the native HDF5 backend. HugeUltraHDF5Writer.Builder builder = HugeUltraHDF5Writer.Builder() .setDestination(dest) + .setVoidMode(voidMode()) + .setSpatial(params.spatial) + .setFeatures(params.features) + .setCores(params.cores); + if (source != null) builder.setSource(source); + if (sources != null) builder.setSources(sources); + if (params.workdir != null) { + params.workdir.mkdirs(); + builder.setWorkDirectory(params.workdir.toPath()); + } + return builder.build(); + } + case 5 -> { + // plaid: hugeUltra plus parallel multi-file ingest - up to + // -cores documents parse concurrently. The engine of choice + // for -merge over many files. Needs the native HDF5 backend. + PlaidHDF5Writer.Builder builder = PlaidHDF5Writer.Builder() + .setDestination(dest) + .setVoidMode(voidMode()) .setSpatial(params.spatial) .setFeatures(params.features) .setCores(params.cores); @@ -489,6 +534,7 @@ private BeakGraphWriter newWriter(File source, List sources, File dest) th default -> { HDF5Writer.Builder builder = HDF5Writer.Builder() .setDestination(dest) + .setVoidMode(voidMode()) .setSpatial(params.spatial) .setFeatures(params.features); if (source != null) builder.setSource(source); diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java index 237759c3..a0de3d90 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/MethodValidator.java @@ -6,7 +6,8 @@ /** * Validates {@code -method}: 0 = sequential in-memory, 1 = disk-based (huge), * 2 = parallel in-memory, 3 = ultra in-memory, 4 = parallel disk-based - * (hugeUltra). + * (hugeUltra), 5 = parallel disk-based with parallel multi-file ingest + * (plaid). */ public class MethodValidator implements IParameterValidator { @@ -15,13 +16,13 @@ public void validate(String name, String value) throws ParameterException { boolean ok; try { int v = Integer.parseInt(value); - ok = v >= 0 && v <= 4; + ok = v >= 0 && v <= 5; } catch (NumberFormatException e) { ok = false; } if (!ok) { throw new ParameterException("Parameter " + name + " must be 0 (in-memory), 1 (disk), " - + "2 (parallel), 3 (ultra), or 4 (hugeUltra disk); found \"" + value + "\""); + + "2 (parallel), 3 (ultra), 4 (hugeUltra disk), or 5 (plaid disk); found \"" + value + "\""); } } } diff --git a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java index f2f2593a..bcf4b5d6 100644 --- a/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java +++ b/src/main/java/com/ebremer/beakgraph/cmdline/Parameters.java @@ -26,6 +26,24 @@ public class Parameters { @Parameter(names = "-dest", description = "Destination Folder or File", required = false) public File dest = null; + @Parameter(names = {"-void"}, converter = BooleanConverter.class, + description = + """ + Generate the VoID/SD statistics graph (urn:x-beakgraph:void) using + EXACT in-memory counting (RAM grows with distinct terms per graph). + Without -void or -voidsketch, no statistics graph is written and + readers fall back to a fixed join-reorder heuristic. + Mutually exclusive with -voidsketch + """) + public boolean voidExact = false; + + @Parameter(names = {"-voidsketch"}, converter = BooleanConverter.class, + description = "Generate the VoID/SD statistics graph with BOUNDED memory: exact up " + + "to 65536 distinct nodes per counter, then HyperLogLog estimates " + + "(~0.8% error, deterministic). Recommended for the disk writers " + + "(-method 1/4/5). Mutually exclusive with -void") + public boolean voidSketch = false; + @Parameter(names = {"-spatial"}, converter = BooleanConverter.class) public boolean spatial = false; @@ -64,7 +82,10 @@ public class Parameters { + "(com.ebremer.beakgraph.hdf5.writers.hugeUltra) for multi-billion-quad " + "builds: bounded RAM like -method 1, but with radix-sorted bit-packed " + "spill runs, background spilling, and concurrent pipeline stages on " - + "-cores threads (needs the native HDF5 backend; honors -workdir)") + + "-cores threads (needs the native HDF5 backend; honors -workdir), " + + "5 = plaid (com.ebremer.beakgraph.hdf5.writers.plaid): method 4 plus " + + "PARALLEL MULTI-FILE INGEST - up to -cores source documents parse " + + "concurrently; the fastest option for -merge over many files") public int method = 0; @Parameter(names = "-cores", validateWith = PositiveInteger.class, diff --git a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java index cf661786..fcf49e87 100644 --- a/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/core/AbstractGraphBuilder.java @@ -13,6 +13,7 @@ public abstract class AbstractGraphBuilder> { protected Dataset ds; protected boolean spatial; protected boolean features; + protected VoidMode voidMode = VoidMode.NONE; // Force the concrete class to return 'this' protected abstract T self(); @@ -39,6 +40,20 @@ public T setSources(List files) { public List getSources() { return sources; } + /** + * Whether/how the VoID+SD statistics graph is generated: {@code NONE} + * (default), {@code EXACT} (in-memory, CLI -void), or {@code SKETCH} + * (bounded-memory HyperLogLog, CLI -voidsketch). + */ + public T setVoidMode(VoidMode mode) { + this.voidMode = mode; + return self(); + } + + public VoidMode getVoidMode() { + return voidMode; + } + public T setSpatial(boolean flag) { this.spatial = flag; return self(); diff --git a/src/main/java/com/ebremer/beakgraph/core/VoidMode.java b/src/main/java/com/ebremer/beakgraph/core/VoidMode.java new file mode 100644 index 00000000..dddd5e08 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/VoidMode.java @@ -0,0 +1,22 @@ +package com.ebremer.beakgraph.core; + +/** + * How (and whether) a writer generates the VoID/SD statistics graph + * ({@code urn:x-beakgraph:void}). + * + *
      + *
    • {@link #NONE} - default: no statistics graph is written. Smallest, + * fastest builds; readers fall back to a fixed join-reorder heuristic.
    • + *
    • {@link #EXACT} (CLI {@code -void}) - fully in-memory, exact counts; + * RAM grows with the number of distinct terms per graph.
    • + *
    • {@link #SKETCH} (CLI {@code -voidsketch}) - bounded memory: exact up + * to 65,536 distinct nodes per counter, then HyperLogLog estimates + * (~0.8% error, deterministic). The right choice for disk-based builds + * that still want statistics-driven query optimization.
    • + *
    + */ +public enum VoidMode { + NONE, + EXACT, + SKETCH +} diff --git a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java index 14eb25b1..6295c721 100644 --- a/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java +++ b/src/main/java/com/ebremer/beakgraph/core/fuseki/BGVoIDSD.java @@ -5,6 +5,8 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; import java.util.regex.Pattern; import org.apache.jena.datatypes.xsd.XSDDatatype; import org.apache.jena.graph.Node; @@ -20,26 +22,58 @@ import org.apache.jena.vocabulary.VOID; /** - * A class for accumulating statistics over quads in an RDF dataset and generating - * VoID and SPARQL Service Description (sd:) metadata. + * A class for accumulating statistics over quads in an RDF dataset and + * generating VoID and SPARQL Service Description (sd:) metadata. + * + *

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

    Thread-safe. The one soft spot is the spill instant itself: an add that + * races the fold can be missed, bounding the error by the number of threads + * active at that moment - noise at the cardinalities where the sketch is in + * play. + */ +final class DistinctNodeCounter { + + private final int exactLimit; + private volatile Set exact = ConcurrentHashMap.newKeySet(); + private volatile HyperLogLog sketch; + + DistinctNodeCounter(int exactLimit) { + this.exactLimit = exactLimit; + } + + void add(Node n) { + Set e = exact; + if (e != null) { + e.add(n); + if (e.size() > exactLimit) { + spill(); + } + return; + } + sketch.add(hash(n)); + } + + private synchronized void spill() { + Set e = exact; + if (e == null) { + return; // another thread already spilled + } + HyperLogLog h = new HyperLogLog(); + for (Node n : e) { + h.add(hash(n)); + } + sketch = h; // publish before dropping the set: count() never sees both null + exact = null; + } + + long count() { + Set e = exact; + return (e != null) ? e.size() : sketch.estimate(); + } + + /** True while counts are still exact (used by reporting/tests). */ + boolean isExact() { + return exact != null; + } + + /** + * 64-bit content hash of a node: a rolling polynomial over the term's + * textual identity (kind-tagged; literals include lexical form, datatype, + * and language), finished with a splitmix64 avalanche. Full 64-bit space, + * so hash collisions are negligible even at 10^10 distinct terms. + */ + static long hash(Node n) { + long h; + if (n.isURI()) { + h = poly(11, n.getURI()); + } else if (n.isBlank()) { + h = poly(13, n.getBlankNodeLabel()); + } else if (n.isLiteral()) { + h = poly(17, n.getLiteralLexicalForm()); + h = h * 31 + poly(19, n.getLiteralDatatypeURI()); + String lang = n.getLiteralLanguage(); + if (lang != null && !lang.isEmpty()) { + h = h * 31 + poly(23, lang); + } + } else { + h = poly(29, n.toString()); + } + return HyperLogLog.mix64(h); + } + + private static long poly(long seed, String s) { + long h = seed * 0x9E3779B97F4A7C15L; + for (int i = 0; i < s.length(); i++) { + h = 31 * h + s.charAt(i); + } + return h; + } +} diff --git a/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java b/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java new file mode 100644 index 00000000..258eec01 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/core/lib/HyperLogLog.java @@ -0,0 +1,63 @@ +package com.ebremer.beakgraph.core.lib; + +import java.util.concurrent.atomic.AtomicIntegerArray; + +/** + * A minimal, thread-safe HyperLogLog cardinality sketch: 2^14 registers + * (64 KiB as ints), standard error about 0.81%. Registers only ever move up + * (CAS-max), so concurrent adds from any number of threads are safe and the + * final state - and therefore the estimate - is a pure, order-independent + * function of the input set: two builds that feed the same distinct values + * report the same number, regardless of thread interleaving. + * + *

    Callers supply well-mixed 64-bit hashes; the top {@code P} bits pick the + * register and the remainder's leading-zero count is the rank. Includes the + * linear-counting small-range correction, which makes estimates for + * cardinalities far below the register count exact in practice. + */ +public final class HyperLogLog { + + private static final int P = 14; + private static final int M = 1 << P; + private static final double ALPHA = 0.7213 / (1 + 1.079 / M); + + private final AtomicIntegerArray registers = new AtomicIntegerArray(M); + + /** Records one 64-bit hash. Thread-safe. */ + public void add(long hash) { + int idx = (int) (hash >>> (64 - P)); + long w = hash << P; + int rank = (w == 0) ? (64 - P + 1) : Long.numberOfLeadingZeros(w) + 1; + int cur; + while ((cur = registers.get(idx)) < rank) { + if (registers.compareAndSet(idx, cur, rank)) { + break; + } + } + } + + /** The cardinality estimate (rounded). */ + public long estimate() { + double sum = 0; + int zeros = 0; + for (int i = 0; i < M; i++) { + int r = registers.get(i); + sum += 1.0 / (1L << r); + if (r == 0) { + zeros++; + } + } + double e = ALPHA * M * (double) M / sum; + if (e <= 2.5 * M && zeros > 0) { + e = M * Math.log((double) M / zeros); // small-range (linear counting) + } + return Math.round(e); + } + + /** splitmix64 finalizer: turns any 64-bit value into a well-mixed hash. */ + public static long mix64(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } +} diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java index 1bf66496..cdaeaab7 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/HDF5Writer.java @@ -43,6 +43,7 @@ public void write() throws IOException { try (PositionalDictionaryWriter w = db .setSource(builder.getSource()) .setSources(builder.getSources()) + .setVoidMode(builder.getVoidMode()) .setDestination(builder.getDestination()) .setName(Params.DICTIONARY) .setSpatial(builder.getSpatial()) diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java index c7f18624..4edb3eb8 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/PositionalDictionaryWriterBuilder.java @@ -140,7 +140,16 @@ public class PositionalDictionaryWriterBuilder { public static final int MAX_INDEX_SCALE = 30; - private BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); + // Null when voidMode == NONE (the default): no statistics are collected + // and no urn:x-beakgraph:void graph is written. + private BGVoIDSD xvoid; + private com.ebremer.beakgraph.core.VoidMode voidMode = com.ebremer.beakgraph.core.VoidMode.NONE; + + /** VoID statistics mode (NONE default, EXACT = -void, SKETCH = -voidsketch). */ + public PositionalDictionaryWriterBuilder setVoidMode(com.ebremer.beakgraph.core.VoidMode mode) { + this.voidMode = mode; + return this; + } public File getDestination() { return dest; } public Quad[] getQuads() { return quads; } @@ -641,6 +650,7 @@ protected final void parse() throws IOException { throw new IllegalStateException("No source set: call setSource() or setSources()"); } final List inputs = sources.isEmpty() ? List.of(src) : List.copyOf(sources); + this.xvoid = BGVoIDSD.forMode(voidMode, "https://ebremer.com/void/"); for (File input : inputs) { parseSource(input, quadcount); // Blank-node labels are document-scoped in RDF: two sources may both @@ -650,23 +660,25 @@ protected final void parse() throws IOException { // labels unique across the whole (possibly merged) store. bmap.clear(); } - // VoID/SD metadata accumulated over ALL sources - Model xxx = xvoid.getModel(); - xxx.setNsPrefix("void", VOID.NS); - xxx.setNsPrefix("sd", SD.getURI()); - xxx.setNsPrefix("xsd", XSD.getURI()); - xxx.setNsPrefix("rdfs", RDFS.getURI()); - xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); - xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); - xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); - xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); - xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); - xvoid.getModel().listStatements().forEach(s->{ - Triple ff = s.asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); - ProcessQuad(qqq); - quadslist.add(qqq); - }); + // VoID/SD metadata accumulated over ALL sources (only when requested) + if (xvoid != null) { + Model xxx = xvoid.getModel(); + xxx.setNsPrefix("void", VOID.NS); + xxx.setNsPrefix("sd", SD.getURI()); + xxx.setNsPrefix("xsd", XSD.getURI()); + xxx.setNsPrefix("rdfs", RDFS.getURI()); + xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); + xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); + xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); + xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); + xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + xxx.listStatements().forEach(s -> { + Triple ff = s.asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(BGVOID, ff)); + ProcessQuad(qqq); + quadslist.add(qqq); + }); + } // Set sum logic for backward compatibility in Stats object stats.numGraphs = entities.size(); stats.numSubjects = entities.size(); @@ -717,7 +729,9 @@ private void parseSource(File input, AtomicLong quadcount) throws IOException { // its dictionary accounting (the quad is already in quadslist, so a // skip would only fail later, opaquely, when the index can't locate it). ProcessQuad(quad); - xvoid.add(quad); + if (xvoid != null) { + xvoid.add(quad); + } if (spatial && isGeoLiteral(quad)) { spatialTasks.add(scope.submit(() -> addSpatial(quad))); } diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java index dcf75a3d..48c23015 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/HugeUltraHDF5Writer.java @@ -75,7 +75,8 @@ public void write() throws IOException { UltraSorterProvider provider = new UltraSorterProvider( builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn, pool); try (HugeBuildPipeline pipeline = new HugeBuildPipeline( - inputs, builder.getSpatial(), builder.getFeatures(), workspace, provider, pool)) { + inputs, builder.getSpatial(), builder.getFeatures(), builder.getVoidMode(), + workspace, provider, pool)) { pipeline.run(tmp); } try { diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java index 1b11f2c9..0ad03889 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/hugeUltra/UltraSorterProvider.java @@ -26,14 +26,14 @@ * varint row list) instead of once per occurrence. Id records (row joins and * encoded quads) sort on bit-packed primitive sorters with no objects at all. */ -final class UltraSorterProvider implements SorterProvider { +public final class UltraSorterProvider implements SorterProvider { private final int termSpillBatch; private final int idSpillBatch; private final int fanIn; private final ForkJoinPool pool; - UltraSorterProvider(int termSpillBatch, int idSpillBatch, int fanIn, ForkJoinPool pool) { + public UltraSorterProvider(int termSpillBatch, int idSpillBatch, int fanIn, ForkJoinPool pool) { this.termSpillBatch = termSpillBatch; this.idSpillBatch = idSpillBatch; this.fanIn = fanIn; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java index 22c747ea..b8d8afbc 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/parallel/ParallelHDF5Writer.java @@ -60,6 +60,7 @@ public void write() throws IOException { ForkJoinPool pool = new ForkJoinPool(builder.getCores()); try { ParallelPositionalDictionaryWriterBuilder db = new ParallelPositionalDictionaryWriterBuilder(); + db.setVoidMode(builder.getVoidMode()); try (ParallelPositionalDictionaryWriter w = db .setSource(builder.getSource()) .setSources(builder.getSources()) diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java new file mode 100644 index 00000000..c27acc29 --- /dev/null +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidHDF5Writer.java @@ -0,0 +1,169 @@ +package com.ebremer.beakgraph.hdf5.writers.plaid; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.AbstractGraphBuilder; +import com.ebremer.beakgraph.core.BeakGraphWriter; +import com.ebremer.beakgraph.hdf5.writers.hugeUltra.UltraSorterProvider; +import com.ebremer.beakgraph.huge.HugeBuildPipeline; +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The plaid writer (CLI: {@code -method 5}): everything the hugeUltra + * disk-based writer is - bounded RAM at any quad count, radix-sorted + * bit-packed spill runs, background spilling, concurrent pipeline stages - + * plus PARALLEL MULTI-FILE INGEST. Where methods 1 and 4 parse sources one + * after another on a single thread, plaid parses up to {@code -cores} + * documents concurrently ({@link PlaidIngest}); only the row-assignment sink + * remains serial. For merges of many files (the natural shape of + * billion-quad inputs) this converts the parse phase - the wall-clock + * majority of a method-4 build - from ~2 busy cores to genuinely + * {@code -cores} busy cores. + * + *

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

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

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

    Rows therefore interleave nondeterministically across runs - and it + * does not matter: row numbers are internal, every artifact the store keeps + * is sorted, and the output is identical to a method-1/4 build of the same + * sources. Best suited to merges of many files; a single-file build degrades + * gracefully to one parse worker (method-4 behaviour). + */ +package com.ebremer.beakgraph.hdf5.writers.plaid; diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java index f8187a09..5df1bbd1 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraHDF5Writer.java @@ -75,6 +75,7 @@ public void write() throws IOException { ingest.setSource(builder.getSource()); ingest.setSources(builder.getSources()); ingest.setSpatial(builder.getSpatial()); + ingest.setVoidMode(builder.getVoidMode()); ingest.setDestination(builder.getDestination()); ingest.setFeatures(builder.getFeatures()); ingest.setName(Params.DICTIONARY); diff --git a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java index f7731f85..59e9bf2b 100644 --- a/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java +++ b/src/main/java/com/ebremer/beakgraph/hdf5/writers/ultra/UltraIngest.java @@ -80,7 +80,8 @@ public final class UltraIngest extends PositionalDictionaryWriterBuilder { private final Set uniqueObjects = ConcurrentHashMap.newKeySet(); private final Set dataTypes = ConcurrentHashMap.newKeySet(); private final Stats ustats = new Stats(); - private final BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); + private com.ebremer.beakgraph.core.VoidMode uVoidMode = com.ebremer.beakgraph.core.VoidMode.NONE; + private BGVoIDSD xvoid; // null when uVoidMode == NONE private Quad[] quads; private long numQuads; @@ -112,6 +113,13 @@ public UltraIngest setSpatial(boolean flag) { return this; } + @Override + public UltraIngest setVoidMode(com.ebremer.beakgraph.core.VoidMode mode) { + this.uVoidMode = mode; + super.setVoidMode(mode); + return this; + } + // ------------------------------------------------------------------ // overridden state getters // ------------------------------------------------------------------ @@ -147,6 +155,7 @@ public void ingest(ForkJoinPool pool) throws IOException { } final List inputs = usources.isEmpty() ? List.of(usrc) : List.copyOf(usources); final boolean multi = inputs.size() > 1; + this.xvoid = BGVoIDSD.forMode(uVoidMode, "https://ebremer.com/void/"); final long ingestStart = System.nanoTime(); logger.info("Ultra ingest: parsing {} source document(s) on {} threads (spatial={})", inputs.size(), pool.getParallelism(), uspatial); @@ -183,22 +192,23 @@ public void ingest(ForkJoinPool pool) throws IOException { logger.info("All {} document(s) parsed in {} ms", inputs.size(), (System.nanoTime() - ingestStart) / 1_000_000L); - // ---- VoID/SD metadata quads over ALL sources (same block as the - // sequential builder; the model itself is small) ---- - Model xxx = xvoid.getModel(); - xxx.setNsPrefix("void", VOID.NS); - xxx.setNsPrefix("sd", SD.getURI()); - xxx.setNsPrefix("xsd", XSD.getURI()); - xxx.setNsPrefix("rdfs", RDFS.getURI()); - xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); - xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); - xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); - xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); - xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + // ---- VoID/SD metadata quads over ALL sources (only when requested) ---- final ArrayList voidQuads = new ArrayList<>(); - xxx.listStatements().forEach(s -> - voidQuads.add(canonicalizeNumericObject(Quad.create(BGVOID, s.asTriple())))); - logger.info("VoID/SD metadata generated: {} statements", voidQuads.size()); + if (xvoid != null) { + Model xxx = xvoid.getModel(); + xxx.setNsPrefix("void", VOID.NS); + xxx.setNsPrefix("sd", SD.getURI()); + xxx.setNsPrefix("xsd", XSD.getURI()); + xxx.setNsPrefix("rdfs", RDFS.getURI()); + xxx.setNsPrefix("geo", "http://www.opengis.net/ont/geosparql#"); + xxx.setNsPrefix("prov", "http://www.w3.org/ns/prov#"); + xxx.setNsPrefix("dct", "http://purl.org/dc/terms/"); + xxx.setNsPrefix("hal", "https://halcyon.is/ns/"); + xxx.setNsPrefix("exif", "http://www.w3.org/2003/12/exif/ns#"); + xxx.listStatements().forEach(s -> + voidQuads.add(canonicalizeNumericObject(Quad.create(BGVOID, s.asTriple())))); + logger.info("VoID/SD metadata generated: {} statements", voidQuads.size()); + } // ---- assemble the quad array (documents in input order: main quads, // then that document's spatial/feature quads - the sequential layout) ---- @@ -272,7 +282,9 @@ private DocResult parseDocument(File input, int docIndex, boolean multi) throws if (main.size() % 100_000 == 0) { logger.info("{}: loaded {} quads...", input.getName(), main.size()); } - xvoid.add(quad); + if (xvoid != null) { + xvoid.add(quad); + } if (uspatial && isGeoLiteral(quad)) { spatialTasks.add(scope.submit(() -> addSpatial(quad))); } diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java index 23f3c50c..35069145 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeBuildPipeline.java @@ -70,7 +70,8 @@ public final class HugeBuildPipeline implements AutoCloseable { // Same sentinel base as the RAM builder: relative references resolve against // it during parsing and are stripped back to relative form for storage. - private static final String REL_BASE = "http://beakgraph.invalid/document"; + // Public: parallel-ingest implementations must parse against the same base. + public static final String REL_BASE = "http://beakgraph.invalid/document"; private static final String REL_BASE_PREFIX = "http://beakgraph.invalid/"; private static final IRIx REL_BASE_IRIX = IRIx.create(REL_BASE); @@ -89,7 +90,35 @@ public final class HugeBuildPipeline implements AutoCloseable { // and final rank ids once the set is complete. private final HashMap predTempIds = new HashMap<>(); private final ArrayList predByTempId = new ArrayList<>(); - private final BGVoIDSD xvoid = new BGVoIDSD("https://ebremer.com/void/"); + /** Null when voidMode == NONE (the default): no statistics graph is written. */ + private final BGVoIDSD xvoid; + /** + * A pluggable Pass A: parse the sources with whatever concurrency the + * implementation likes, delivering TRANSFORMED quads (default-graph + * rewrite, bnode scoping, relativize, numeric canonicalization, + * spatial/feature augmentation already applied - the public static + * helpers on this class plus {@link SpatialAugmenter} are the shared + * implementations) in batches to the sink. The sink is the pipeline's one + * serial section: it assigns row numbers and feeds the sorters, keeping + * the positional predicate column's entry-i-equals-row-i invariant + * without any post-sort. The plaid writer (-method 5) plugs in per-file + * parallel parsing here. + */ + public interface ParallelIngest { + void run(List sources, boolean spatial, boolean features, + BGVoIDSD voidStats, BatchSink sink) throws IOException; + + interface BatchSink { + /** + * Appends a batch of rows to the store. {@code sourceQuads} = + * how many of them were parsed from a document (vs derived + * spatial/feature quads); drives the numQuads attribute and + * progress logging. Thread-safe; callers may commit concurrently. + */ + void commit(List batch, long sourceQuads) throws IOException; + } + } + private RecordSorter gSorter; private RecordSorter sSorter; private RecordSorter oSorter; @@ -97,16 +126,19 @@ public final class HugeBuildPipeline implements AutoCloseable { private final SorterProvider provider; /** Non-null: independent stage groups run concurrently on it (-method 4). */ private final java.util.concurrent.ExecutorService stagePool; + /** Non-null: Pass A runs through it instead of the sequential loop (-method 5). */ + private final ParallelIngest parallelIngest; private long rows = 0; private long parsedQuads = 0; // ---- Build products (owned; released in close()) ---- private final List resources = new ArrayList<>(); - HugeBuildPipeline(List sources, boolean spatial, boolean features, Path workDir, + HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, int termSpillBatch, int idSpillBatch, int mergeFanIn) { - this(sources, spatial, features, workDir, - SorterProvider.sequential(termSpillBatch, idSpillBatch, mergeFanIn), null); + this(sources, spatial, features, voidMode, workDir, + SorterProvider.sequential(termSpillBatch, idSpillBatch, mergeFanIn), null, null); } /** @@ -116,17 +148,27 @@ public final class HugeBuildPipeline implements AutoCloseable { * This is how the hugeUltra writer (-method 4) turbocharges the pipeline * without changing its flow or output. */ - public HugeBuildPipeline(List sources, boolean spatial, boolean features, Path workDir, + public HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, SorterProvider provider, java.util.concurrent.ExecutorService stagePool) { + this(sources, spatial, features, voidMode, workDir, provider, stagePool, null); + } + + public HugeBuildPipeline(List sources, boolean spatial, boolean features, + com.ebremer.beakgraph.core.VoidMode voidMode, Path workDir, + SorterProvider provider, java.util.concurrent.ExecutorService stagePool, + ParallelIngest parallelIngest) { if (sources.isEmpty()) { throw new IllegalArgumentException("At least one source document is required"); } this.sources = List.copyOf(sources); this.spatial = spatial; this.features = features; + this.xvoid = BGVoIDSD.forMode(voidMode, "https://ebremer.com/void/"); this.workDir = workDir; this.provider = provider; this.stagePool = stagePool; + this.parallelIngest = parallelIngest; this.gSorter = track(provider.termSorter(workDir, "gcol")); this.sSorter = track(provider.termSorter(workDir, "scol")); this.oSorter = track(provider.termSorter(workDir, "ocol")); @@ -358,6 +400,11 @@ public void run(Path tmpH5) throws IOException { private void ingest() throws IOException { this.pTempFile = new RecordFile<>(workDir.resolve("pcol.tmpids"), HugeRecords.VAR_LONG_CODEC); + if (parallelIngest != null) { + parallelIngest.run(sources, spatial, features, xvoid, this::commitBatch); + finishIngest(); + return; + } for (int i = 0; i < sources.size(); i++) { // Blank-node labels are document-scoped in RDF: two merged sources // may both say _:b0 and mean different nodes (labels are parsed as @@ -368,17 +415,42 @@ private void ingest() throws IOException { // labels from ids. Single-source builds stay untouched. ingestSource(sources.get(i), sources.size() > 1 ? (i + "/") : null); } - // VoID/SD metadata quads over ALL sources (mirrors the RAM writer; the - // model's namespace prefixes are presentation-only and irrelevant to quads). - for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { - Triple ff = it.next().asTriple(); - Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); - acceptRow(qqq); + finishIngest(); + } + + /** VoID/SD metadata quads over ALL sources (when requested), then the p-column seal. */ + private void finishIngest() throws IOException { + if (xvoid != null) { + // (mirrors the RAM writer; the model's namespace prefixes are + // presentation-only and irrelevant to quads) + for (Iterator it = xvoid.getModel().listStatements(); it.hasNext(); ) { + Triple ff = it.next().asTriple(); + Quad qqq = canonicalizeNumericObject(Quad.create(Params.BGVOID, ff)); + acceptRow(qqq); + } } pTempFile.finish(); logger.info("Parse complete: {} source quads, {} total rows", parsedQuads, rows); } + /** + * The parallel-ingest sink: the ONE serial section of Pass A. Row numbers, + * the positional predicate column, statistics, and the sorter buffers all + * advance under this lock, so every single-threaded invariant of + * {@link #acceptRow} holds unchanged; spill sorting/writing still happens + * on background workers, so the lock is held only for buffer appends. + */ + private synchronized void commitBatch(List batch, long sourceQuads) throws IOException { + for (Quad q : batch) { + acceptRow(q); + } + long before = parsedQuads; + parsedQuads += sourceQuads; + if (before / 1_000_000 != parsedQuads / 1_000_000) { + logger.info("Loaded {} quads...", parsedQuads); + } + } + private void ingestSource(File input, String bnodeScope) throws IOException { logger.info("Parsing {} (disk-based build)", input); // Syntax from the file name (TriG, N-Quads, N-Triples, RDF/XML, JSON-LD, @@ -400,8 +472,8 @@ private void ingestSource(File input, String bnodeScope) throws IOException { ? new Quad(Quad.defaultGraphIRI, quad.getSubject(), quad.getPredicate(), quad.getObject()) : quad) .map(quad -> scopeBlankNodes(quad, bnodeScope)) - .map(this::relativize) - .map(this::canonicalizeNumericObject) + .map(HugeBuildPipeline::relativize) + .map(HugeBuildPipeline::canonicalizeNumericObject) .forEach(quad -> { parsedQuads++; if (parsedQuads % 1_000_000 == 0) { @@ -409,7 +481,9 @@ private void ingestSource(File input, String bnodeScope) throws IOException { } try { acceptRow(quad); - xvoid.add(quad); + if (xvoid != null) { + xvoid.add(quad); + } if (spatial && isGeoLiteral(quad)) { inFlight.add(scope.submit(() -> augmenter.addSpatial(quad))); while (inFlight.size() >= maxInFlight) { @@ -432,7 +506,7 @@ private void ingestSource(File input, String bnodeScope) throws IOException { } /** See ingest(): per-document blank-node scoping for -merge builds; no-op when scope is null. */ - private static Quad scopeBlankNodes(Quad q, String scope) { + public static Quad scopeBlankNodes(Quad q, String scope) { if (scope == null) { return q; } @@ -581,7 +655,7 @@ private static boolean isGeoLiteral(Quad quad) { // Quad transforms (mirrors of PositionalDictionaryWriterBuilder) // ------------------------------------------------------------------ - private Quad relativize(Quad q) { + public static Quad relativize(Quad q) { Node qg = q.getGraph(); Node qs = q.getSubject(); Node qp = q.getPredicate(); @@ -596,7 +670,7 @@ private Quad relativize(Quad q) { return new Quad(g, s, p, o); } - private Node relativizeNode(Node n) { + private static Node relativizeNode(Node n) { if (n == null || !n.isURI() || !n.getURI().startsWith(REL_BASE_PREFIX)) { return n; } @@ -613,7 +687,7 @@ private Node relativizeNode(Node n) { u.equals(REL_BASE) ? "" : u.substring(REL_BASE_PREFIX.length())); } - private Quad canonicalizeNumericObject(Quad quad) { + public static Quad canonicalizeNumericObject(Quad quad) { Node o = quad.getObject(); if (!o.isLiteral()) return quad; String dt = o.getLiteralDatatypeURI(); diff --git a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java index 9d7660b1..b26fe803 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java +++ b/src/main/java/com/ebremer/beakgraph/huge/HugeHDF5Writer.java @@ -74,7 +74,7 @@ public void write() throws IOException { : builder.getSources(); try { try (HugeBuildPipeline pipeline = new HugeBuildPipeline( - inputs, builder.getSpatial(), builder.getFeatures(), + inputs, builder.getSpatial(), builder.getFeatures(), builder.getVoidMode(), workspace, builder.termSpillBatch, builder.idSpillBatch, builder.mergeFanIn)) { pipeline.run(tmp); } diff --git a/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java b/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java index 7b847137..e6e7f4a7 100644 --- a/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java +++ b/src/main/java/com/ebremer/beakgraph/huge/SpatialAugmenter.java @@ -37,7 +37,7 @@ * * @author Erich Bremer */ -final class SpatialAugmenter { +public final class SpatialAugmenter { private static final Logger logger = LoggerFactory.getLogger(SpatialAugmenter.class); @@ -51,11 +51,11 @@ final class SpatialAugmenter { private final boolean features; - SpatialAugmenter(boolean features) { + public SpatialAugmenter(boolean features) { this.features = features; } - static boolean isGeoLiteral(Quad quad) { + public static boolean isGeoLiteral(Quad quad) { Node o = quad.getObject(); return o.isLiteral() && GEO.wktLiteral.getURI().equals(o.getLiteralDatatypeURI()); } @@ -65,7 +65,7 @@ static boolean isGeoLiteral(Quad quad) { * geometry never aborts the build - it is logged and skipped, exactly like * the RAM writer. */ - ArrayList addSpatial(Quad quad) { + public ArrayList addSpatial(Quad quad) { final ArrayList qqq = new ArrayList<>(); String wkt = ImageTools.stripCrs(quad.getObject().getLiteralLexicalForm()); if (features) { diff --git a/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java b/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java index 80143943..403d2815 100644 --- a/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java +++ b/src/test/java/com/ebremer/beakgraph/DistinctCorrectnessTest.java @@ -51,7 +51,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("distinct.ttl").toFile(); File h5 = dir.resolve("distinct.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); diff --git a/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java b/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java index 38065236..3dfa505f 100644 --- a/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java +++ b/src/test/java/com/ebremer/beakgraph/HighSeverityFixesRoundTripTest.java @@ -82,7 +82,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("highsev.ttl").toFile(); File h5 = dir.resolve("highsev.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); diff --git a/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java b/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java index 8c93f58d..9ebd6b1d 100644 --- a/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java +++ b/src/test/java/com/ebremer/beakgraph/QuadFormatIngestionTest.java @@ -51,7 +51,7 @@ void trigSourceWithNamedGraphRoundTrips() throws Exception { File src = dir.resolve("data.trig").toFile(); File h5 = dir.resolve("data.trig.h5").toFile(); Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); @@ -73,7 +73,7 @@ void nquadsSourceRoundTrips() throws Exception { File src = dir.resolve("data.nq").toFile(); File h5 = dir.resolve("data.nq.h5").toFile(); Files.write(src.toPath(), nq.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); @@ -87,7 +87,7 @@ void emptySourceProducesConsistentFile() throws Exception { File src = dir.resolve("empty.ttl").toFile(); File h5 = dir.resolve("empty.ttl.h5").toFile(); Files.write(src.toPath(), "# nothing here\n".getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { Dataset ds = bg.getDataset(); diff --git a/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java b/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java index 46b7b9ab..3f8eb207 100644 --- a/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java +++ b/src/test/java/com/ebremer/beakgraph/ReorderQueryTest.java @@ -59,7 +59,7 @@ static void build() throws Exception { try (OutputStream out = Files.newOutputStream(ttl.toPath())) { RDFDataMgr.write(out, m, Lang.TURTLE); } - HDF5Writer.Builder().setSource(ttl).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(ttl).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); bg = new BeakGraph(new HDF5Reader(h5), h5.toURI()); ds = bg.getDataset(); } diff --git a/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java b/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java index bd24fcf6..02327016 100644 --- a/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java +++ b/src/test/java/com/ebremer/beakgraph/UnionGraphAndDetachTest.java @@ -65,7 +65,7 @@ static void buildAndOpen() throws Exception { File ttl = dir.resolve("union.ttl").toFile(); File h5 = dir.resolve("union.ttl.h5").toFile(); Files.write(ttl.toPath(), TTL.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder() + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT) .setSource(ttl).setDestination(h5) .setSpatial(false).setFeatures(false) .build().write(); diff --git a/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java b/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java index 1162e1d4..a8875a82 100644 --- a/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java +++ b/src/test/java/com/ebremer/beakgraph/VariableGraphScanTest.java @@ -49,7 +49,7 @@ static void build() throws Exception { File t = dir.resolve("vg.ttl").toFile(); h5 = dir.resolve("vg.ttl.h5").toFile(); Files.write(t.toPath(), ttl.toString().getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(t).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(t).setDestination(h5).setSpatial(false).setFeatures(false).build().write(); } @Test diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java index f544625f..1a04ecbc 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/BeakGraphCLIConversionTest.java @@ -127,8 +127,16 @@ void methodAndCoresOptionsParse() { assertEquals(0, new Parameters().method, "-method must default to the in-memory writer"); org.junit.jupiter.api.Assertions.assertThrows(com.beust.jcommander.ParameterException.class, () -> com.beust.jcommander.JCommander.newBuilder().addObject(new Parameters()).build() - .parse("-src", "x", "-method", "5"), - "-method outside 0..4 must be rejected"); + .parse("-src", "x", "-method", "6"), + "-method outside 0..5 must be rejected"); + } + + @Test + void methodFiveConvertsThroughPlaidWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + convertsWithMethod(5, "plaid"); } @Test diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java index 52040251..679d344d 100644 --- a/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java +++ b/src/test/java/com/ebremer/beakgraph/cmdline/MergeAndFormatsTest.java @@ -247,6 +247,27 @@ void mergeWithHugeUltraWriter() throws Exception { assertMerged(p.dest); } + @Test + void mergeWithPlaidWriter() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue( + com.ebremer.beakgraph.huge.NativeHdf5File.isAvailable(), + "native HDF5 library unavailable"); + Path src = mergeSourceTree("srcmergeplaid"); + Parameters p = new Parameters(); + p.src = src.toFile(); + p.dest = dir.resolve("outmergeplaid").resolve("all.h5").toFile(); + p.merge = true; + p.method = 5; + p.cores = 3; + p.workdir = dir.resolve("workmergeplaid").toFile(); + + BeakGraphCLI cli = new BeakGraphCLI(p); + cli.merge(); + + assertEquals(0, cli.getFileCounter().getFailedConversionFileCount(), "the -method 5 merge must succeed"); + assertMerged(p.dest); + } + @Test void mergeIntoExistingDirectoryWritesMergedH5() throws Exception { Path src = mergeSourceTree("srcmergedir"); diff --git a/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java b/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java new file mode 100644 index 00000000..f5d6da0e --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/cmdline/VoidModeTest.java @@ -0,0 +1,100 @@ +package com.ebremer.beakgraph.cmdline; + +import com.ebremer.beakgraph.Params; +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.core.VoidMode; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * VoID generation policy: OFF by default, exact with -void, HyperLogLog with + * -voidsketch, and refusal when both are requested. + */ +class VoidModeTest { + + @TempDir + Path dir; + + private File build(String name, VoidMode mode) throws Exception { + File src = dir.resolve(name + ".ttl").toFile(); + Files.write(src.toPath(), + (" .\n" + + " \"x\" .\n").getBytes(StandardCharsets.UTF_8)); + File h5 = dir.resolve(name + ".h5").toFile(); + HDF5Writer.Builder().setSource(src).setDestination(h5).setVoidMode(mode).build().write(); + return h5; + } + + private boolean hasVoidGraph(File h5) throws Exception { + try (BeakGraph bg = new BeakGraph(new HDF5Reader(h5))) { + try (QueryExecution qe = QueryExecution.dataset(bg.getDataset()) + .query(QueryFactory.create( + "ASK { GRAPH <" + Params.VOIDSTRING + "> { ?s ?p ?o } }")).build()) { + return qe.execAsk(); + } + } + } + + @Test + void noVoidGraphByDefault() throws Exception { + assertFalse(hasVoidGraph(build("off", VoidMode.NONE)), + "without -void/-voidsketch the statistics graph must not exist"); + } + + @Test + void exactAndSketchModesWriteTheVoidGraph() throws Exception { + assertTrue(hasVoidGraph(build("exact", VoidMode.EXACT)), "-void must write the statistics graph"); + assertTrue(hasVoidGraph(build("sketch", VoidMode.SKETCH)), "-voidsketch must write the statistics graph"); + } + + @Test + void cliOptionsParseAndMapToModes() { + Parameters p = new Parameters(); + com.beust.jcommander.JCommander.newBuilder().addObject(p).build() + .parse("-src", "x", "-voidsketch"); + assertTrue(p.voidSketch); + assertFalse(p.voidExact); + assertFalse(new Parameters().voidExact, "VoID must be OFF by default"); + assertFalse(new Parameters().voidSketch, "VoID must be OFF by default"); + } + + @Test + void voidAndVoidSketchTogetherAreRefused() { + Parameters p = new Parameters(); + p.src = dir.toFile(); + p.voidExact = true; + p.voidSketch = true; + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> new BeakGraphCLI(p), + "-void plus -voidsketch must refuse to run"); + assertTrue(ex.getMessage().contains("mutually exclusive")); + } + + @Test + void voidGraphIsTheOnlyDifference() throws Exception { + // Same data, void off vs on: the data graphs must be identical. + File off = build("cmp-off", VoidMode.NONE); + File on = build("cmp-on", VoidMode.EXACT); + try (BeakGraph a = new BeakGraph(new HDF5Reader(off)); + BeakGraph b = new BeakGraph(new HDF5Reader(on))) { + assertTrue(a.getDataset().getDefaultModel().isIsomorphicWith(b.getDataset().getDefaultModel())); + assertFalse(a.getDataset().asDatasetGraph().listGraphNodes().hasNext(), + "no named graphs at all without void"); + assertEquals(Params.VOIDSTRING, + b.getDataset().asDatasetGraph().listGraphNodes().next().getURI(), + "with -void the only named graph is the statistics graph"); + } + } +} diff --git a/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java b/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java index 6e2f0db4..e6c0eb6b 100644 --- a/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java +++ b/src/test/java/com/ebremer/beakgraph/core/VoidStatsTest.java @@ -39,7 +39,7 @@ void predicateCountsSumAcrossGraphPartitions() throws Exception { File src = dir.resolve("stats.trig").toFile(); File h5 = dir.resolve("stats.trig.h5").toFile(); Files.write(src.toPath(), trig.getBytes(StandardCharsets.UTF_8)); - HDF5Writer.Builder().setSource(src).setDestination(h5) + HDF5Writer.Builder().setVoidMode(com.ebremer.beakgraph.core.VoidMode.EXACT).setSource(src).setDestination(h5) .setSpatial(false).setFeatures(false).build().write(); try (HDF5Reader reader = new HDF5Reader(h5)) { diff --git a/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java b/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java new file mode 100644 index 00000000..14148f40 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/core/fuseki/VoidSketchTest.java @@ -0,0 +1,101 @@ +package com.ebremer.beakgraph.core.fuseki; + +import com.ebremer.beakgraph.core.lib.HyperLogLog; +import java.util.List; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.VOID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + +/** + * The bounded-memory VoID statistics: HyperLogLog accuracy, the + * exact-then-sketch spill of {@link DistinctNodeCounter}, determinism, and - + * most importantly - that BELOW the spill threshold every reported VoID + * number is exactly what the old retained-set implementation produced. + */ +class VoidSketchTest { + + @Test + void hyperLogLogIsAccurateAtScale() { + HyperLogLog hll = new HyperLogLog(); + int n = 1_000_000; + for (long i = 0; i < n; i++) { + hll.add(HyperLogLog.mix64(i * 0x9E3779B97F4A7C15L + 12345)); + } + long est = hll.estimate(); + // 2^14 registers -> sigma ~0.81%; 3 sigma bound with margin. + assertTrue(Math.abs(est - n) < n * 0.03, + "estimate " + est + " must be within 3% of " + n); + } + + @Test + void counterIsExactBelowLimitAndCloseAboveIt() { + DistinctNodeCounter small = new DistinctNodeCounter(1 << 16); + for (int i = 0; i < 5_000; i++) { + small.add(NodeFactory.createURI("http://ex.org/r" + (i % 1_000))); + } + assertTrue(small.isExact()); + assertEquals(1_000, small.count(), "below the limit the count is exact"); + + DistinctNodeCounter big = new DistinctNodeCounter(1_000); + int n = 50_000; + for (int i = 0; i < n; i++) { + big.add(NodeFactory.createURI("http://ex.org/r" + i)); + } + assertFalse(big.isExact(), "the counter must have spilled to the sketch"); + assertTrue(Math.abs(big.count() - n) < n * 0.05, + "sketched count " + big.count() + " must be within 5% of " + n); + + // Determinism: same inputs -> same estimate, regardless of duplicates. + DistinctNodeCounter again = new DistinctNodeCounter(1_000); + for (int round = 0; round < 2; round++) { + for (int i = 0; i < n; i++) { + again.add(NodeFactory.createURI("http://ex.org/r" + i)); + } + } + assertEquals(big.count(), again.count(), "estimates are a pure function of the distinct set"); + } + + @Test + void smallStoreVoidNumbersAreExact() { + BGVoIDSD v = new BGVoIDSD("https://ebremer.com/void/"); + for (int i = 0; i < 200; i++) { + v.add(new Quad(Quad.defaultGraphIRI, + NodeFactory.createURI("http://ex.org/data/s" + (i % 40)), + NodeFactory.createURI("http://ex.org/p" + (i % 5)), + NodeFactory.createURI("http://ex.org/data/o" + (i % 60)))); + } + for (int i = 0; i < 30; i++) { + v.add(new Quad(Quad.defaultGraphIRI, + NodeFactory.createURI("http://ex.org/data/s" + i), + RDF.type.asNode(), + NodeFactory.createURI("http://ex.org/Article"))); + } + Model m = v.getModel(); + assertEquals(40, one(m, VOID.distinctSubjects), "distinct subjects exact"); + assertEquals(61, one(m, VOID.distinctObjects), "distinct objects exact (60 + the class)"); + assertEquals(1, one(m, VOID.classes)); + assertEquals(30, one(m, VOID.entities), "typed instances exact"); + assertEquals(230, one(m, VOID.triples)); + // uriSpace: LCP of http://ex.org/data/s*, cut to the last '/' + List spaces = m.listObjectsOfProperty(VOID.uriSpace) + .mapWith(n -> n.asLiteral().getString()).toList(); + assertEquals(List.of("http://ex.org/data/"), spaces, "incremental LCP must match the old set-based one"); + assertTrue(m.listObjectsOfProperty(VOID.vocabulary) + .mapWith(n -> n.asResource().getURI()).toSet() + .contains("http://ex.org/"), + "object/predicate namespaces must land in void:vocabulary"); + } + + private static long one(Model m, org.apache.jena.rdf.model.Property p) { + var it = m.listObjectsOfProperty(p); + assertTrue(it.hasNext(), "expected a value for " + p); + return it.next().asLiteral().getLong(); + } +} diff --git a/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java b/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java new file mode 100644 index 00000000..246c5960 --- /dev/null +++ b/src/test/java/com/ebremer/beakgraph/hdf5/writers/plaid/PlaidWriterParityTest.java @@ -0,0 +1,151 @@ +package com.ebremer.beakgraph.hdf5.writers.plaid; + +import com.ebremer.beakgraph.core.BeakGraph; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.writers.HDF5Writer; +import com.ebremer.beakgraph.huge.NativeHdf5File; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QueryFactory; +import org.apache.jena.rdf.model.Model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The plaid writer's whole point is many-file merges with concurrent + * parsing, so the parity test IS a many-file merge: 24 documents (more than + * the parse workers, so workers cycle through several files and their row + * batches interleave nondeterministically), duplicates across files, shared + * terms, per-document _:b0 blank nodes, and named graphs - built with tiny + * spill batches, then compared against the sequential in-memory writer's + * merge of the same sources. + */ +class PlaidWriterParityTest { + + @TempDir + static Path dir; + + @BeforeAll + static void requireNativeHdf5() { + Assumptions.assumeTrue(NativeHdf5File.isAvailable(), "native HDF5 library unavailable"); + } + + private static Set graphNames(org.apache.jena.query.Dataset ds) { + Set names = new TreeSet<>(); + ds.asDatasetGraph().listGraphNodes().forEachRemaining(g -> names.add(g.toString())); + return names; + } + + private static Model graphModel(org.apache.jena.query.Dataset ds, String graphUri) { + String q = (graphUri == null) + ? "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + : "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + graphUri + "> { ?s ?p ?o } }"; + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(q)).build()) { + return qe.execConstruct(); + } + } + + private static long count(org.apache.jena.query.Dataset ds, String query) { + try (QueryExecution qe = QueryExecution.dataset(ds).query(QueryFactory.create(query)).build()) { + return qe.execSelect().next().getLiteral("n").getLong(); + } + } + + @Test + void manyFileMergeMatchesSequentialMerge() throws Exception { + Path src = Files.createDirectories(dir.resolve("plaidsrc")); + List inputs = new ArrayList<>(); + for (int f = 0; f < 24; f++) { + StringBuilder nq = new StringBuilder(); + for (int i = 0; i < 400; i++) { + int k = f * 400 + i; + // Shared subjects/objects across files force cross-file dictionary dedup. + nq.append(" "); + nq.append(switch (k % 3) { + case 0 -> ""; + case 1 -> "\"v" + (k % 150) + "\""; + default -> "\"" + (k % 90 - 45) + "\"^^"; + }); + if (k % 4 == 0) { + nq.append(" '); + } + nq.append(" .\n"); + // Deliberate duplicates within and across files. + if (k % 50 == 0) { + nq.append(" .\n"); + } + } + // The SAME bnode label in every document: 24 distinct nodes. + nq.append("_:b0 \"doc").append(f).append("\" .\n"); + File file = src.resolve(String.format("part%02d.nq", f)).toFile(); + Files.write(file.toPath(), nq.toString().getBytes(StandardCharsets.UTF_8)); + inputs.add(file); + } + + File seq = dir.resolve("plaid.seq.h5").toFile(); + File plaid = dir.resolve("plaid.plaid.h5").toFile(); + HDF5Writer.Builder().setSources(inputs).setDestination(seq).build().write(); + PlaidHDF5Writer.Builder() + .setSources(inputs) + .setDestination(plaid) + .setWorkDirectory(Files.createDirectories(dir.resolve("plaidwork"))) + .setCores(6) // more files than workers: batches interleave heavily + .setTermSpillBatch(512) + .setIdSpillBatch(1024) + .setMergeFanIn(3) + .build() + .write(); + + try (BeakGraph a = new BeakGraph(new HDF5Reader(seq)); + BeakGraph b = new BeakGraph(new HDF5Reader(plaid))) { + org.apache.jena.query.Dataset da = a.getDataset(); + org.apache.jena.query.Dataset db = b.getDataset(); + assertEquals(graphNames(da), graphNames(db), "graph lists must match"); + assertTrue(graphModel(da, null).isIsomorphicWith(graphModel(db, null)), + "default graphs must be isomorphic"); + for (String g : graphNames(da)) { + if (!g.startsWith("http")) continue; + assertTrue(graphModel(da, g).isIsomorphicWith(graphModel(db, g)), + "graph <" + g + "> must be isomorphic"); + } + assertEquals(count(da, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"), + count(db, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"), + "default-graph size must match (duplicates collapsed identically)"); + assertEquals(24, count(db, "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE { ?b ?v }"), + "_:b0 from 24 documents must remain 24 distinct blank nodes"); + } + } + + @Test + void singleFileBuildStillWorks() throws Exception { + File src = dir.resolve("one.ttl").toFile(); + Files.write(src.toPath(), + " .\n \"x\" .\n" + .getBytes(StandardCharsets.UTF_8)); + File out = dir.resolve("one.plaid.h5").toFile(); + PlaidHDF5Writer.Builder() + .setSource(src) + .setDestination(out) + .setWorkDirectory(Files.createDirectories(dir.resolve("onework"))) + .setCores(2) + .build() + .write(); + try (BeakGraph bg = new BeakGraph(new HDF5Reader(out))) { + assertTrue(bg.find(org.apache.jena.graph.NodeFactory.createURI("http://ex.org/a"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/p"), + org.apache.jena.graph.NodeFactory.createURI("http://ex.org/b")).hasNext()); + } + } +} From 5154776a0893fb4fc9453f60dfc55cb544be040a Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sat, 4 Jul 2026 19:40:21 -0400 Subject: [PATCH 09/17] add benchmarks --- .gitignore | 1 + README.md | 10 ++ benchmarks/README.md | 80 +++++++++ benchmarks/pom.xml | 105 ++++++++++++ .../benchmarks/BitPackedBufferBench.java | 110 ++++++++++++ .../beakgraph/benchmarks/DictionaryBench.java | 152 +++++++++++++++++ .../beakgraph/benchmarks/QueryBench.java | 161 ++++++++++++++++++ .../beakgraph/benchmarks/SelectBench.java | 95 +++++++++++ .../beakgraph/benchmarks/SyntheticStore.java | 93 ++++++++++ 9 files changed, 807 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/pom.xml create mode 100644 benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java create mode 100644 benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/DictionaryBench.java create mode 100644 benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/QueryBench.java create mode 100644 benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java create mode 100644 benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SyntheticStore.java diff --git a/.gitignore b/.gitignore index 93869936..40cffca0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target/ +/benchmarks/target/ *.log .claude/settings.local.json *.h5 diff --git a/README.md b/README.md index be458976..01e5d395 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ Core Library Jar Library mvn -Plib clean package ``` +## Benchmarks + +JMH benchmarks for the read path live in [`benchmarks/`](benchmarks/) as a standalone module: + +``` +mvn -DskipTests install # install BeakGraph into the local repo +cd benchmarks && mvn package # build the benchmarks jar +java -jar target/benchmarks.jar # run (see benchmarks/README.md for options) +``` + ## Using BeakGraph in your code ### Creating a BeakGraph from your data diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..373fbff7 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,80 @@ +# BeakGraph JMH Benchmarks + +[JMH](https://github.com/openjdk/jmh) benchmarks for the BeakGraph **read path**. Standalone +Maven project on purpose: it depends on the *installed* BeakGraph artifact, so benchmark code +never touches the main build or its shaded artifact. + +## Build + +```bash +# 1. Install the current BeakGraph into the local Maven repo (repository root) +mvn -DskipTests install + +# 2. Build the benchmarks jar (this directory) +cd benchmarks +mvn package +``` + +## Run + +```bash +# Everything (takes a while) +java -jar target/benchmarks.jar + +# One benchmark class / method, by regex +java -jar target/benchmarks.jar QueryBench +java -jar target/benchmarks.jar "BitPackedBufferBench.randomGet" + +# List what's available +java -jar target/benchmarks.jar -l +``` + +The first run of the store-backed benchmarks (`SelectBench`, `DictionaryBench`, `QueryBench`) +generates a deterministic synthetic store into `target/jmh-data/store-.h5` and +reuses it afterwards. Delete that directory (or `mvn clean`) to force a rebuild. + +### Useful knobs + +```bash +# Store size (subjects; the store holds 4 triples per subject + subjects/4 tagged quads) +java -jar target/benchmarks.jar QueryBench -p subjects=200000 + +# Quick-and-dirty iteration while developing (not for reported numbers) +java -jar target/benchmarks.jar QueryBench.pointLookup -f 1 -wi 2 -i 3 -p subjects=5000 + +# Allocation rates per op (watch this when attacking per-row garbage) +java -jar target/benchmarks.jar QueryBench.chainJoin -prof gc + +# Flame graphs, if async-profiler is installed +java -jar target/benchmarks.jar QueryBench.predicateScan -prof "async:output=flamegraph" + +# JSON results for before/after comparison +java -jar target/benchmarks.jar -rf json -rff results.json +``` + +For trustworthy numbers: plug in the laptop, close the browser, and prefer `-f 2` (two forks) +or more for anything you plan to quote. + +## What is measured + +| Class | Level | Targets | +|---|---|---| +| `BitPackedBufferBench` | primitive | `BitPackedUnSignedLongBuffer.get()` (random + sequential), `binarySearch`, the streaming decoder — the innermost decode loop everything else sits on | +| `SelectBench` | primitive | `select1` via the rank/select directory vs the linear fallback, plus the adjacent-pair pattern the iterators actually issue | +| `DictionaryBench` | dictionary | `locate` (entity/object/predicate hits, misses), `extract`, and the Caffeine-cached node-table path | +| `QueryBench` | end-to-end | SPARQL shapes: point lookup, star join, predicate scan, FILTER range pushdown, chain join (per-binding iterator reconstruction), `GRAPH ?g` scan, and a Graph-API full scan | + +Benchmarks are single-threaded by default; the store and its readers are safe for concurrent +reads, so `-t ` is a valid experiment for contention questions (the probe cursors are +deliberately racy — they only pick probe values). + +## Baseline discipline + +When working on a read-path optimization: + +1. Record a baseline first: `java -jar target/benchmarks.jar -rf json -rff baseline.json` +2. Make the change in the main project, then `mvn -DskipTests install` at the root and + `mvn package` here again (the jar embeds BeakGraph classes — rebuilding the benchmarks + jar is required to pick up main-project changes). +3. Re-run the same selection and compare against the baseline, ideally with `-prof gc` when + the change is about allocation. diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml new file mode 100644 index 00000000..a6fe7079 --- /dev/null +++ b/benchmarks/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + com.ebremer + beakgraph-benchmarks + BeakGraph JMH Benchmarks + 0.17.0 + jar + + + JMH micro/meso benchmarks for the BeakGraph read path. Standalone on purpose: + it depends on the INSTALLED BeakGraph artifact so the main build, its shaded + artifact, and its enforcer rules are never disturbed by benchmark code. + Build the parent first: mvn -DskipTests install (from the repository root). + + + + UTF-8 + 25 + 1.37 + + 0.17.0 + 3.15.0 + 3.6.1 + + + + + + com.ebremer + BeakGraph + ${beakgraph.version} + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + package + + shade + + + benchmarks + + + org.openjdk.jmh.Main + + + true + ALL-UNNAMED + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java new file mode 100644 index 00000000..9226d3f1 --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/BitPackedBufferBench.java @@ -0,0 +1,110 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import java.nio.file.Path; +import java.util.SplittableRandom; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * The read path's innermost primitive: bit-packed value decode. + * + *

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

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

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

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

    Every query drains its ResultSet and touches each projected term, so lazy + * bindings actually materialize - the timings include dictionary extraction. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(value = 1, jvmArgsAppend = { + "--enable-native-access=ALL-UNNAMED", + "--sun-misc-unsafe-memory-access=allow", + "-Xmx4g"}) +@State(Scope.Benchmark) +public class QueryBench { + + private static final String PREFIX = "PREFIX ex: <" + SyntheticStore.NS + ">\n"; + + @Param({"50000"}) + public int subjects; + + private BeakGraph graph; + private Dataset dataset; + private Query pointLookup; + private Query starJoin; + private Query predicateScan; + private Query rangeFilter; + private Query chainJoin; + private Query graphVarScan; + + @Setup + public void setup() { + graph = new BeakGraph(new HDF5Reader(SyntheticStore.get(subjects).toFile())); + dataset = graph.getDataset(); + int mid = subjects / 2; + pointLookup = QueryFactory.create(PREFIX + + "SELECT ?o WHERE { ex:s" + mid + " ex:link ?o }"); + starJoin = QueryFactory.create(PREFIX + + "SELECT ?v ?n ?t WHERE { ex:s" + mid + " ex:value ?v ; ex:name ?n ; ex:link ?t }"); + predicateScan = QueryFactory.create(PREFIX + + "SELECT ?s ?v WHERE { ?s ex:value ?v }"); + rangeFilter = QueryFactory.create(PREFIX + + "SELECT ?s ?v WHERE { ?s ex:value ?v FILTER(?v >= 995) }"); + chainJoin = QueryFactory.create(PREFIX + + "SELECT ?a ?c WHERE { ?a ex:value 7 . ?a ex:link ?b . ?b ex:link ?c }"); + graphVarScan = QueryFactory.create(PREFIX + + "SELECT ?g ?s WHERE { GRAPH ?g { ?s ex:tag ?t } }"); + } + + @TearDown + public void tearDown() { + graph.close(); + } + + /** Runs the query and touches every projected term so results fully materialize. */ + private long run(Query q) { + long h = 0; + try (QueryExecution qe = QueryExecution.dataset(dataset).query(q).build()) { + ResultSet rs = qe.execSelect(); + List vars = rs.getResultVars(); + while (rs.hasNext()) { + QuerySolution row = rs.next(); + for (String v : vars) { + RDFNode n = row.get(v); + if (n != null) { + h += n.asNode().hashCode(); + } + } + } + } + return h; + } + + @Benchmark + public long pointLookup() { + return run(pointLookup); + } + + @Benchmark + public long starJoin() { + return run(starJoin); + } + + @Benchmark + public long predicateScan() { + return run(predicateScan); + } + + @Benchmark + public long rangeFilter() { + return run(rangeFilter); + } + + @Benchmark + public long chainJoin() { + return run(chainJoin); + } + + @Benchmark + public long graphVarScan() { + return run(graphVarScan); + } + + @Benchmark + public long fullScanFind() { + long h = 0; + var it = graph.find(Node.ANY, Node.ANY, Node.ANY); + try { + while (it.hasNext()) { + h += it.next().hashCode(); + } + } finally { + it.close(); + } + return h; + } +} diff --git a/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java new file mode 100644 index 00000000..a8d448fb --- /dev/null +++ b/benchmarks/src/main/java/com/ebremer/beakgraph/benchmarks/SelectBench.java @@ -0,0 +1,95 @@ +package com.ebremer.beakgraph.benchmarks; + +import com.ebremer.beakgraph.hdf5.BitPackedUnSignedLongBuffer; +import com.ebremer.beakgraph.hdf5.Index; +import com.ebremer.beakgraph.hdf5.readers.HDF5Reader; +import com.ebremer.beakgraph.hdf5.readers.IndexReader; +import com.ebremer.beakgraph.utils.HDTBitmapDirectory; +import java.util.SplittableRandom; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * select1 on a real store's GSPO object-level bitmap: the accelerated + * superblock/block directory ({@link HDTBitmapDirectory}) vs the linear + * word-scan fallback. Every iterator construction performs several of these, + * and the GPOS nested scan performs two per object group, so this is the + * navigation primitive behind index traversal. + * + *

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

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

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

      + *
    • Default graph, per subject {@code s<i>} (4 triples each): + * {@code rdf:type ex:Widget} (one low-selectivity predicate), + * {@code ex:name "widget-<i>"} (distinct string literals), + * {@code ex:value <i mod 1000>} (shared integer literals - range-filter fodder), + * {@code ex:link s<(7i+13) mod subjects>} (URI objects forming join chains).
    • + *
    • {@code subjects/4} extra quads spread round-robin over {@value #NAMED_GRAPHS} + * named graphs {@code ex:g<k>}: {@code s<i> ex:tag <k>} - so + * GRAPH-variable and union scans have real multi-graph work to do.
    • + *
    + */ +public final class SyntheticStore { + + public static final String NS = "http://bench.beakgraph.org/"; + public static final int NAMED_GRAPHS = 8; + + private SyntheticStore() {} + + /** Index of the subject that {@code s}'s ex:link points at. */ + public static int linkTarget(int i, int subjects) { + return (int) ((7L * i + 13) % subjects); + } + + /** Path of the store for {@code subjects}, building it first if absent. */ + public static synchronized Path get(int subjects) { + Path dir = Paths.get(System.getProperty("beakgraph.bench.data", "target/jmh-data")); + Path h5 = dir.resolve("store-" + subjects + ".h5"); + if (Files.exists(h5)) { + return h5; + } + try { + Files.createDirectories(dir); + Path trig = dir.resolve("store-" + subjects + ".trig"); + long t0 = System.nanoTime(); + System.err.printf("[SyntheticStore] generating %,d subjects -> %s%n", subjects, trig); + generate(trig, subjects); + System.err.printf("[SyntheticStore] building store -> %s%n", h5); + HDF5Writer.Builder() + .setSource(trig.toFile()) + .setDestination(h5.toFile()) + .setSpatial(false) + .setFeatures(false) + .build() + .write(); + System.err.printf("[SyntheticStore] ready in %.1f s (%,d bytes)%n", + (System.nanoTime() - t0) / 1e9, Files.size(h5)); + } catch (IOException e) { + throw new UncheckedIOException("Failed to build benchmark store " + h5, e); + } + return h5; + } + + private static void generate(Path trig, int subjects) throws IOException { + try (BufferedWriter w = Files.newBufferedWriter(trig, StandardCharsets.UTF_8)) { + w.write("@prefix ex: <" + NS + "> .\n"); + w.write("@prefix rdf: .\n\n"); + for (int i = 0; i < subjects; i++) { + w.write("ex:s" + i + + " a ex:Widget ; ex:name \"widget-" + i + "\" ; ex:value " + (i % 1000) + + " ; ex:link ex:s" + linkTarget(i, subjects) + " .\n"); + } + int tagged = subjects / 4; + for (int k = 0; k < NAMED_GRAPHS; k++) { + w.write("\nex:g" + k + " {\n"); + for (int i = k; i < tagged; i += NAMED_GRAPHS) { + w.write(" ex:s" + i + " ex:tag " + k + " .\n"); + } + w.write("}\n"); + } + } + } +} From 9e388f08bc5027322ad7fe5780db59a6eca0c448 Mon Sep 17 00:00:00 2001 From: Erich Bremer Date: Sun, 5 Jul 2026 16:45:23 -0400 Subject: [PATCH 10/17] fixes --- docs/INSTRUCTIONS.md | 1 + pom.xml | 2 +- .../beakgraph/cmdline/BeakGraphCLI.java | 2 + .../ebremer/beakgraph/cmdline/Parameters.java | 6 + .../com/ebremer/beakgraph/core/BeakGraph.java | 26 +-- .../core/fuseki/BGSparqlService.java | 38 +++- .../hdf5/jena/AggregateCountFastPath.java | 152 +++++++++++++++ .../ebremer/beakgraph/hdf5/jena/BGReader.java | 7 + .../hdf5/jena/DistinctPredicateFastPath.java | 169 ++++++++++++++++ .../beakgraph/hdf5/jena/OpExecutorBG.java | 39 +++- .../beakgraph/hdf5/readers/HDF5Reader.java | 7 + .../beakgraph/hdf5/readers/IndexCounts.java | 123 ++++++++++++ .../beakgraph/AggregateCountFastPathTest.java | 181 ++++++++++++++++++ .../DistinctPredicateFastPathTest.java | 162 ++++++++++++++++ .../ebremer/beakgraph/LargeDataSpaceTest.java | 86 +++++++++ 15 files changed, 982 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/jena/AggregateCountFastPath.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/jena/DistinctPredicateFastPath.java create mode 100644 src/main/java/com/ebremer/beakgraph/hdf5/readers/IndexCounts.java create mode 100644 src/test/java/com/ebremer/beakgraph/AggregateCountFastPathTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/DistinctPredicateFastPathTest.java create mode 100644 src/test/java/com/ebremer/beakgraph/LargeDataSpaceTest.java diff --git a/docs/INSTRUCTIONS.md b/docs/INSTRUCTIONS.md index b8da2b43..1be83938 100644 --- a/docs/INSTRUCTIONS.md +++ b/docs/INSTRUCTIONS.md @@ -70,6 +70,7 @@ java -jar BeakGraph.jar -endpoint out/example.h5 -port 8888 | `-status` | off | Progress bar (per-file mode) and end-of-run counters. | | `-endpoint ` | — | Serve the store as a SPARQL endpoint instead of converting. | | `-port ` | `8888` | HTTP port for `-endpoint`. | +| `-timeout ` | `30` | Per-query wall-clock limit in seconds for `-endpoint`; a query over the limit is cancelled and answered with HTTP 503. `0` disables the limit. | | `-version` / `-v` | — | Print version and exit. | | `-help` | — | Usage text. | diff --git a/pom.xml b/pom.xml index 4d7201a9..f681189c 100644 --- a/pom.xml +++ b/pom.xml @@ -52,7 +52,7 @@ log4j-config-yaml module is not needed and does not exist for 2.x. --> 2.25.2 2.0.17 - 0.10.0 + 0.12.0