diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index 33866ba..c0e9cc7 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -5,4 +5,7 @@ plugins { dependencies { implementation(project(":brev-documents")) + implementation("no.digipost:peppol-bis-billing-3-generator-api:v0.5-billing-3.0.18") + testImplementation("com.helger.phive.rules:phive-rules-peppol:4.5.3") + testRuntimeOnly("org.glassfish.jaxb:jaxb-runtime:4.0.9") } diff --git a/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java b/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java index a160f16..20cbfec 100644 --- a/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java +++ b/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java @@ -1,19 +1,7 @@ package no.beint.brev.benchmark; -import no.beint.brev.CountryCode; -import no.beint.brev.CurrencyCode; -import no.beint.brev.EndpointId; -import no.beint.brev.SchemeId; -import no.beint.brev.UnitCode; import no.beint.brev.documents.BillingDocument; -import no.beint.brev.documents.BillingLine; import no.beint.brev.documents.Documents; -import no.beint.brev.documents.Party; -import no.beint.brev.documents.PaymentInstruction; -import no.beint.brev.documents.PostalAddress; -import no.beint.brev.documents.Quantity; -import no.beint.brev.documents.UnitPrice; -import no.beint.brev.documents.VatCategory; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -23,9 +11,9 @@ import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Warmup; +import peppol.bis.invoice3.api.PeppolBillingApi; -import java.math.BigDecimal; -import java.time.LocalDate; +import java.io.IOException; import java.util.concurrent.TimeUnit; @State(Scope.Benchmark) @@ -35,42 +23,17 @@ @Measurement(iterations = 5) @Fork(2) public class BillingWriterBenchmark { - private static final CurrencyCode NOK = new CurrencyCode("NOK"); - private final BillingDocument invoice = createInvoice(); + private final BillingDocument brevInvoice = BillingBenchmarkFixture.createBrevInvoice(); + private final peppol.bis.invoice3.domain.Invoice digipostInvoice = + BillingBenchmarkFixture.createDigipostInvoice(); @Benchmark - public byte[] writeInvoice() { - return Documents.toByteArray(invoice); + public byte[] brevSerialization() { + return Documents.toByteArray(brevInvoice); } - private static BillingDocument createInvoice() { - PostalAddress address = new PostalAddress("Testveien 1", "Oslo", "0150", new CountryCode("NO")); - Party seller = Party.withVat( - new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "913341464"), - "Seller AS", - "913341464", - "NO913341464MVA", - address); - Party buyer = Party.withoutVat( - new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "987654321"), - "Buyer AS", - "987654321", - address); - return BillingDocument.invoice() - .id("BENCH-1") - .issueDate(LocalDate.of(2026, 8, 17)) - .dueDate(LocalDate.of(2026, 9, 1)) - .currency(NOK) - .buyerReference("benchmark") - .seller(seller) - .buyer(buyer) - .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")) - .line(new BillingLine( - "1", - "Consulting", - new Quantity(new BigDecimal("10"), UnitCode.HOUR), - new UnitPrice(NOK, new BigDecimal("1250")), - VatCategory.standard(new BigDecimal("25")))) - .build(); + @Benchmark + public byte[] digipostSerialization() throws IOException { + return PeppolBillingApi.create(digipostInvoice).inputStream().readAllBytes(); } } diff --git a/benchmark/src/main/java/no/beint/brev/benchmark/BillingBenchmarkFixture.java b/benchmark/src/main/java/no/beint/brev/benchmark/BillingBenchmarkFixture.java new file mode 100644 index 0000000..1329eec --- /dev/null +++ b/benchmark/src/main/java/no/beint/brev/benchmark/BillingBenchmarkFixture.java @@ -0,0 +1,117 @@ +package no.beint.brev.benchmark; + +import no.beint.brev.CountryCode; +import no.beint.brev.CurrencyCode; +import no.beint.brev.EndpointId; +import no.beint.brev.SchemeId; +import no.beint.brev.UnitCode; +import no.beint.brev.documents.BillingDocument; +import no.beint.brev.documents.BillingLine; +import no.beint.brev.documents.Party; +import no.beint.brev.documents.PaymentInstruction; +import no.beint.brev.documents.PostalAddress; +import no.beint.brev.documents.Quantity; +import no.beint.brev.documents.UnitPrice; +import no.beint.brev.documents.VatCategory; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +final class BillingBenchmarkFixture { + private static final CurrencyCode NOK = new CurrencyCode("NOK"); + + private BillingBenchmarkFixture() { + } + + static BillingDocument createBrevInvoice() { + PostalAddress address = new PostalAddress("Testveien 1", "Oslo", "0150", new CountryCode("NO")); + Party seller = Party.withVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "922989451"), + "Seller AS", + "922989451", + "NO922989451MVA", + address); + Party buyer = Party.withoutVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "987654325"), + "Buyer AS", + "987654325", + address); + return BillingDocument.invoice() + .id("BENCH-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("benchmark") + .seller(seller) + .buyer(buyer) + .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")) + .line(new BillingLine( + "1", + "Consulting", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")))) + .build(); + } + + static peppol.bis.invoice3.domain.Invoice createDigipostInvoice() { + peppol.bis.invoice3.domain.AccountingSupplierParty seller = new peppol.bis.invoice3.domain.AccountingSupplierParty( + createDigipostParty("922989451", "Seller AS", true)); + peppol.bis.invoice3.domain.AccountingCustomerParty buyer = new peppol.bis.invoice3.domain.AccountingCustomerParty( + createDigipostParty("987654325", "Buyer AS", false)); + peppol.bis.invoice3.domain.TaxScheme vatScheme = new peppol.bis.invoice3.domain.TaxScheme("VAT"); + peppol.bis.invoice3.domain.TaxCategory taxCategory = new peppol.bis.invoice3.domain.TaxCategory("S", vatScheme) + .withPercent("25"); + peppol.bis.invoice3.domain.TaxTotal taxTotal = new peppol.bis.invoice3.domain.TaxTotal( + new peppol.bis.invoice3.domain.TaxAmount("3125.00", "NOK")) + .withTaxSubtotal(new peppol.bis.invoice3.domain.TaxSubtotal( + new peppol.bis.invoice3.domain.TaxableAmount("12500.00", "NOK"), + new peppol.bis.invoice3.domain.TaxAmount("3125.00", "NOK"), + taxCategory)); + peppol.bis.invoice3.domain.LegalMonetaryTotal totals = new peppol.bis.invoice3.domain.LegalMonetaryTotal( + new peppol.bis.invoice3.domain.LineExtensionAmount("12500.00", "NOK"), + new peppol.bis.invoice3.domain.TaxExclusiveAmount("12500.00", "NOK"), + new peppol.bis.invoice3.domain.TaxInclusiveAmount("15625.00", "NOK"), + new peppol.bis.invoice3.domain.PayableAmount("15625.00", "NOK")); + peppol.bis.invoice3.domain.ClassifiedTaxCategory lineTax = + new peppol.bis.invoice3.domain.ClassifiedTaxCategory("S", new peppol.bis.invoice3.domain.TaxScheme("VAT")) + .withPercent("25"); + peppol.bis.invoice3.domain.InvoiceLine line = new peppol.bis.invoice3.domain.InvoiceLine( + "1", + new peppol.bis.invoice3.domain.InvoicedQuantity("10", "HUR"), + new peppol.bis.invoice3.domain.LineExtensionAmount("12500.00", "NOK"), + new peppol.bis.invoice3.domain.Item("Consulting", lineTax), + new peppol.bis.invoice3.domain.Price(new peppol.bis.invoice3.domain.PriceAmount("1250", "NOK")) + .withBaseQuantity(new peppol.bis.invoice3.domain.BaseQuantity("1").withUnitCode("HUR"))); + peppol.bis.invoice3.domain.PaymentMeans payment = new peppol.bis.invoice3.domain.PaymentMeans( + new peppol.bis.invoice3.domain.PaymentMeansCode("30")) + .withPaymentID("1234567890123456789012345") + .withPayeeFinancialAccount(new peppol.bis.invoice3.domain.PayeeFinancialAccount("NO9386011117947")); + return new peppol.bis.invoice3.domain.Invoice( + "BENCH-1", "2026-08-17", "NOK", seller, buyer, taxTotal, totals, List.of(line)) + .withDueDate("2026-09-01") + .withBuyerReference("benchmark") + .withPaymentMeans(payment); + } + + private static peppol.bis.invoice3.domain.Party createDigipostParty( + String organizationNumber, String name, boolean vatRegistered) { + peppol.bis.invoice3.domain.PostalAddress address = new peppol.bis.invoice3.domain.PostalAddress( + new peppol.bis.invoice3.domain.Country("NO")) + .withStreetName("Testveien 1") + .withCityName("Oslo") + .withPostalZone("0150"); + peppol.bis.invoice3.domain.Party party = new peppol.bis.invoice3.domain.Party( + new peppol.bis.invoice3.domain.EndpointID(organizationNumber).withSchemeID("0192"), + address, + new peppol.bis.invoice3.domain.PartyLegalEntity(name) + .withCompanyID(new peppol.bis.invoice3.domain.CompanyID(organizationNumber).withSchemeID("0192"))) + .withPartyName(new peppol.bis.invoice3.domain.PartyName(name)); + if (vatRegistered) { + party.withPartyTaxScheme(new peppol.bis.invoice3.domain.PartyTaxScheme( + "NO" + organizationNumber + "MVA", new peppol.bis.invoice3.domain.TaxScheme("VAT"))); + } + return party; + } +} diff --git a/benchmark/src/test/java/no/beint/brev/benchmark/BillingWriterBenchmarkTest.java b/benchmark/src/test/java/no/beint/brev/benchmark/BillingWriterBenchmarkTest.java new file mode 100644 index 0000000..a91e692 --- /dev/null +++ b/benchmark/src/test/java/no/beint/brev/benchmark/BillingWriterBenchmarkTest.java @@ -0,0 +1,114 @@ +package no.beint.brev.benchmark; + +import com.helger.phive.api.execute.ValidationExecutionManager; +import com.helger.phive.api.executorset.IValidationExecutorSetRegistry; +import com.helger.phive.api.executorset.ValidationExecutorSetRegistry; +import com.helger.phive.api.validity.IValidityDeterminator; +import com.helger.phive.peppol.PeppolValidation2026_05; +import com.helger.phive.xml.source.IValidationSourceXML; +import com.helger.phive.xml.source.ValidationSourceXML; +import no.beint.brev.documents.Documents; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import peppol.bis.invoice3.api.PeppolBillingApi; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +final class BillingWriterBenchmarkTest { + private static ValidationExecutorSetRegistry registry; + + @BeforeAll + static void registerCurrentBillingRules() { + registry = new ValidationExecutorSetRegistry<>(); + try { + PeppolValidation2026_05.class.getMethod("initBilling", IValidationExecutorSetRegistry.class) + .invoke(null, registry); + } catch (ReflectiveOperationException exception) { + PeppolValidation2026_05.init(registry); + } + } + + @Test + void benchmarkInvoicesContainTheSameBusinessValues() throws Exception { + Document brev = parse(Documents.toByteArray(BillingBenchmarkFixture.createBrevInvoice())); + Document digipost = parse(PeppolBillingApi.create(BillingBenchmarkFixture.createDigipostInvoice()) + .inputStream().readAllBytes()); + + for (String element : List.of( + "ID", + "IssueDate", + "DueDate", + "DocumentCurrencyCode", + "BuyerReference", + "EndpointID", + "RegistrationName", + "StreetName", + "CityName", + "PostalZone", + "IdentificationCode", + "TaxAmount", + "TaxableAmount", + "LineExtensionAmount", + "TaxExclusiveAmount", + "TaxInclusiveAmount", + "PayableAmount", + "InvoicedQuantity", + "Name", + "PriceAmount", + "BaseQuantity", + "PaymentMeansCode", + "PaymentID")) { + assertEquals(values(brev, element), values(digipost, element), element); + } + + assertValid(brev); + assertValid(digipost); + } + + private static void assertValid(Document document) { + var executorSet = registry.getOfID(PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + if (executorSet == null) { + throw new IllegalStateException("current Peppol Billing rules were not registered"); + } + var result = ValidationExecutionManager.executeValidation( + IValidityDeterminator.createDefault(), + executorSet, + ValidationSourceXML.create(null, document)); + assertFalse( + result.containsAtLeastOneError(), + () -> result.getAllErrors().stream() + .map(error -> error.getErrorText(Locale.ENGLISH)) + .collect(Collectors.joining("\n"))); + } + + private static List values(Document document, String localName) throws Exception { + var expression = XPathFactory.newInstance().newXPath() + .compile("//*[local-name()='" + localName + "']/text()"); + var nodes = (org.w3c.dom.NodeList) expression.evaluate(document, XPathConstants.NODESET); + return java.util.stream.IntStream.range(0, nodes.getLength()) + .mapToObj(index -> nodes.item(index).getNodeValue()) + .toList(); + } + + private static Document parse(byte[] xml) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); + } +} diff --git a/docs/performance.md b/docs/performance.md index befcb37..cb5b2a6 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,6 +1,42 @@ # Performance baseline -These numbers are a development smoke test, not a cross-library performance claim. The benchmark currently measures one small positive invoice written through the convenience `toByteArray` path. A publishable comparison requires the same semantic invoice, output destination, JVM settings, and validation policy for Brev and the existing ReAI generator. +These numbers are a development baseline, not a production migration claim. The comparison measures one small positive invoice written to a returned byte array by Brev and by the Digipost generator currently used in ReAI. A fixture test checks that the generated documents contain the same business values. + +## 2026-08-30 Digipost comparison + +- Hardware: Apple M5 Pro, arm64. +- JVM: OpenJDK 26.0.2.1. +- JMH: 1.36. +- Workload: serialization from preconstructed, semantically equivalent one-line invoice models. +- Warmup: 3 × 1 second. +- Measurement: 5 × 1 second. +- Forks: 2. +- GC profiler enabled. + +Command: + +```shell +java -jar benchmark/build/libs/benchmark-0.1.0-jmh.jar \ + '.*BillingWriterBenchmark.*' -wi 3 -i 5 -f 2 -w 1s -r 1s -prof gc +``` + +| Generator | Throughput | Allocation | +|---|---:|---:| +| Brev | 263,003 ops/s | 17,568 B/op | +| Digipost | 38,267 ops/s | 272,016 B/op | + +For this workload, Brev delivered **6.87× the throughput** and used **15.48× less allocation per operation**. The benchmark returns the XML byte array in both cases. The Digipost path mirrors ReAI's `PeppolBillingApi.create(document).inputStream().readAllBytes()` behavior. + +The production artifact envelope for the measured paths is also smaller: + +| Generator | Runtime JARs | Classes | Compressed size | +|---|---:|---:|---:| +| Brev | 2 | 45 | 88,595 bytes | +| Digipost + Eaxy + JSR-305 | 4 | 192 | 233,648 bytes | + +Brev removes two runtime artifacts, 147 classes, and 145,053 compressed bytes from this path. Its production modules retain zero third-party runtime dependencies. + +This clears the repository's initial 5× throughput and 3× allocation gates for the measured invoice. A ReAI migration still requires shadow generation over representative production-shaped invoices, including credit notes, discounts, multiple VAT categories, rounding, BIC details, and attachments. Both outputs must pass the current PHIVE rules before the old generator can be removed. ## 2026-08-14 smoke test @@ -25,26 +61,25 @@ A shorter GC-profiler run reported **17,776 bytes allocated per operation**. Mos ## Artifact size -The initial production JARs are: +The current 0.1.0 production JARs are: | Artifact | Compressed size | |---|---:| -| `brev-core` | 15,653 bytes | -| `brev-billing` | 29,733 bytes | -| Combined | 45,386 bytes | +| `brev-core` | 15,775 bytes | +| `brev-documents` | 72,820 bytes | +| Combined | 88,595 bytes | Both production modules have zero third-party runtime dependencies. The test-only conformance and benchmark modules deliberately carry PHIVE/Saxon/JAXB and JMH respectively. -## Required comparison +## Remaining comparison work Before claiming a speedup, add benchmark cases for: -1. Digipost object construction plus `PeppolBillingApi.create(...).inputStream().readBytes()`; -2. Brev model construction plus `toByteArray()`; -3. serialization alone from preconstructed models; -4. direct streaming to a pre-sized or black-hole output destination; -5. small, typical, large-line-count, and attachment-heavy invoices; -6. cold startup and first-document latency; -7. throughput, bytes allocated per operation, retained memory, and artifact/class count. +1. Digipost and Brev object construction plus serialization; +2. direct streaming to a pre-sized or black-hole output destination; +3. typical, large-line-count, credit-note, discount, multi-VAT, and attachment-heavy invoices; +4. cold startup and first-document latency; +5. retained memory under the ReAI workload; +6. shadow comparison and current PHIVE validation over a representative ReAI invoice corpus. The benchmark input and resulting XML must be semantically equivalent and independently validated.