Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,43 @@ between source sets (the drift that the `jvmShared` set removed).
ktfmt-gradle only derives tasks for the common and JVM source sets, so `library/build.gradle.kts`
registers matching tasks for the Android ones.

## Performance

Conversion itself is a few milliseconds; a CLI run is mostly JVM startup and class loading.
`:benchmark` measures the pipeline in-process, `scripts/benchmark.py` measures whole processes.

```bash
./gradlew :benchmark:run # in-process, per stage
python3 scripts/benchmark.py # whole process, against markitdown and anydoc
```

In-process, best of 50 runs after warmup:

| fixture | size | parse | render | total |
|---|---|---|---|---|
| test.json | 0.4 KB | 0.03 ms | 0.01 ms | 0.04 ms |
| test.epub | 2 KB | 0.42 ms | 0.00 ms | 0.42 ms |
| test_blog.html | 25 KB | 0.85 ms | 0.11 ms | 0.96 ms |
| test.xlsx | 11 KB | 1.09 ms | 0.00 ms | 1.03 ms |
| test.docx | 132 KB | 3.28 ms | 0.00 ms | 2.81 ms |
| test.pdf | 90 KB | 3.69 ms | 0.00 ms | 3.47 ms |
| test.pptx | 271 KB | 4.79 ms | 0.00 ms | 4.53 ms |
| test_wikipedia.html | 385 KB | 12.98 ms | 1.82 ms | 14.80 ms |

End to end the CLI runs in 100–240 ms, against ~25 ms for the Rust
[anydoc](https://github.com/firecrawl/anydoc) and 410–540 ms for Python markitdown. Nearly all of
what is left is process startup: `java -version` alone costs 41 ms on the same machine, and the
conversion is under 5 ms for every fixture but Wikipedia. Matching a native binary would take
ahead-of-time compilation, not a faster pipeline.

The CLI therefore optimizes startup rather than throughput:

- `installDist` records a [class-data-sharing](https://docs.oracle.com/en/java/javase/21/vm/class-data-sharing.html)
archive into the distribution, which roughly halves startup. Set `MIKROMARKDOWN_NO_CDS=1` to skip
it; the start script also skips it when the archive is missing, so `distZip` still works.
- it compiles with C1 only (`-XX:TieredStopAtLevel=1`), since C2 never pays for itself in a run this
short. Embedders using the library get the normal JIT.

## Benchmark

`scripts/benchmark.py` converts the test fixtures with MikroMarkdown, Python
Expand Down
13 changes: 13 additions & 0 deletions benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.kotlin.jvm)
application
}

java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }

application { mainClass = "io.github.lemcoder.mikromarkdown.benchmark.MainKt" }

// Fixtures are addressed from the repository root, not this module's directory.
tasks.named<JavaExec>("run") { workingDir = rootProject.projectDir }

dependencies { implementation(project(":library")) }
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.github.lemcoder.mikromarkdown.benchmark

import io.github.lemcoder.mikromarkdown.MikroMarkdown
import io.github.lemcoder.mikromarkdown.StreamInfo
import java.io.File
import kotlin.system.measureNanoTime

/**
* In-process timings for the conversion pipeline.
*
* The CLI's wall clock is dominated by JVM startup and class loading, which says nothing about the pipeline itself.
* This measures the stages separately on a warmed-up JVM, and separately reports the first conversion in a fresh JVM —
* the one that pays for loading POI, PDFBox and Tika.
*
* Usage: ./gradlew :benchmark:run --args="[fixtureDir] [warmup] [iterations]"
*/
fun main(args: Array<String>) {
// Cold modes run exactly one conversion and exit, so the number includes class loading.
// Comparing them isolates what format detection costs on a cold JVM.
when (args.firstOrNull()) {
"cold-bytes" -> return coldBytes(File(args[1]))
"cold-path" -> return coldPath(File(args[1]))
}

val fixtures = File(args.getOrElse(0) { "library/src/commonTest/resources/test_files" })
val warmup = args.getOrElse(1) { "20" }.toInt()
val iterations = args.getOrElse(2) { "50" }.toInt()

val files = fixtures.listFiles().orEmpty().filter { it.isFile && !it.name.startsWith(".") }.sortedBy { it.name }
require(files.isNotEmpty()) { "no fixtures in ${fixtures.absolutePath}" }

val first = files.first()
val coldStart = measureNanoTime { MikroMarkdown().convert(first.absolutePath) }
report("first conversion in a fresh JVM, class loading included (${first.name})", coldStart)
println()

val mikroMarkdown = MikroMarkdown()

println("Best of $iterations runs after $warmup warmup runs, milliseconds.")
println()
println("| fixture | KB | parse | render | convert(bytes) | convert(path) |")
println("|---|---|---|---|---|---|")

for (file in files) {
val bytes = file.readBytes()
val info = StreamInfo(extension = file.extension, filename = file.name, localPath = file.absolutePath)

repeat(warmup) { mikroMarkdown.convert(bytes, info) }

val parse = best(iterations) { mikroMarkdown.parse(bytes, info) }
val convertBytes = best(iterations) { mikroMarkdown.convert(bytes, info) }
val convertPath = best(iterations) { mikroMarkdown.convert(file.absolutePath) }
// Rendering is whatever convert adds on top of parse; timing it alone would re-parse.
val render = (convertBytes - parse).coerceAtLeast(0)

println(
"| ${file.name} | ${file.length() / 1024} | ${parse.ms()} | ${render.ms()} | " +
"${convertBytes.ms()} | ${convertPath.ms()} |"
)
}
}

private fun coldBytes(file: File) {
val bytes = file.readBytes()
val info = StreamInfo(extension = file.extension, filename = file.name, localPath = file.absolutePath)
val elapsed = measureNanoTime { MikroMarkdown().convert(bytes, info) }
report("cold convert(bytes), no detection (${file.name})", elapsed)
}

private fun coldPath(file: File) {
val elapsed = measureNanoTime { MikroMarkdown().convert(file.absolutePath) }
report("cold convert(path), with detection (${file.name})", elapsed)
}

private fun report(label: String, nanos: Long) = println("$label: ${nanos.ms()} ms")

private fun best(iterations: Int, block: () -> Any?): Long {
var best = Long.MAX_VALUE
repeat(iterations) {
val elapsed = measureNanoTime { block() }
if (elapsed < best) best = elapsed
}
return best
}

private fun Long.ms(): String = "%.2f".format(this / 1_000_000.0)
112 changes: 111 additions & 1 deletion cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,121 @@ plugins {

java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }

application { mainClass = "com.mikromarkdown.cli.MainKt" }
val cdsArchiveName = "mikromarkdown.jsa"

application {
mainClass = "com.mikromarkdown.cli.MainKt"
// Class loading, not conversion, is what a short CLI run spends its time on. A class-data-sharing
// archive maps those classes in pre-parsed. -Xshare:auto keeps the CLI working when it is absent.
// Every CLI run is short, so C2 never pays for itself: compiling with C1 only is faster
// end to end. Long-running embedders use the library directly and are unaffected.
applicationDefaultJvmArgs = listOf("-Xshare:auto", "-XX:-UsePerfData", "-XX:TieredStopAtLevel=1")
}

dependencies {
implementation(project(":library"))
implementation(libs.clikt)
}

tasks.test { useJUnitPlatform() }

// The archive flag is added by the script itself, and only when the archive is there: naming a
// missing archive stops the JVM loading its base one, which is also what recording needs.
tasks.named<CreateStartScripts>("startScripts") {
doLast {
val unixGuard =
"""
CDS_ARCHIVE="${'$'}APP_HOME/lib/$cdsArchiveName"
if [ -f "${'$'}CDS_ARCHIVE" ] && [ -z "${'$'}MIKROMARKDOWN_NO_CDS" ] ; then
DEFAULT_JVM_OPTS="${'$'}DEFAULT_JVM_OPTS \"-XX:SharedArchiveFile=${'$'}CDS_ARCHIVE\""
fi
"""
.trimIndent()

// Lambda form: the guard contains $ sequences that must not be read as group references.
unixScript.writeText(
unixScript.readText().replace(Regex("(?m)^(DEFAULT_JVM_OPTS=.*)${'$'}")) { match ->
"${match.value}\n\n$unixGuard"
}
)

val windowsGuard =
"""
set CDS_ARCHIVE=%APP_HOME%\\lib\\$cdsArchiveName
if exist "%CDS_ARCHIVE%" if not defined MIKROMARKDOWN_NO_CDS set DEFAULT_JVM_OPTS=%DEFAULT_JVM_OPTS% "-XX:SharedArchiveFile=%CDS_ARCHIVE%"
"""
.trimIndent()

windowsScript.writeText(
windowsScript.readText().replace(Regex("(?m)^(set DEFAULT_JVM_OPTS=.*)${'$'}")) { match ->
"${match.value}\r\n$windowsGuard"
}
)
}
}

/**
* Builds a class-data-sharing archive for the installed distribution, in two steps:
*
* 1. run the CLI once with `-XX:DumpLoadedClassList` to learn which classes a conversion touches;
* 2. `-Xshare:dump` that list into an archive.
*
* This static form is used rather than `-XX:ArchiveClassesAtExit` because the dynamic one needs the JDK's own base
* archive, which some distributions (JetBrains Runtime among them) do not ship.
*
* Step 1 goes through the start script so the classpath recorded is the one real runs use; step 2 reads that same
* classpath back out of the script, because a mismatch makes the JVM drop the archive without saying so.
*/
val cdsArchive by tasks.registering {
group = "distribution"
description = "Builds a class-data-sharing archive into the installed distribution."

val installDir = layout.buildDirectory.dir("install/${application.applicationName}").get()
val appName = application.applicationName
val sample = rootProject.layout.projectDirectory.file("library/src/commonTest/resources/test_files/test.docx")
val javaHome = javaToolchains.launcherFor(java.toolchain).get().metadata.installationPath

doLast {
val script = installDir.file("bin/$appName").asFile
val classList = installDir.file("lib/$cdsArchiveName.classlist").asFile
val archive = installDir.file("lib/$cdsArchiveName").asFile
archive.delete()

providers
.exec {
commandLine(script.absolutePath, sample.asFile.absolutePath)
environment("JAVA_OPTS", "-XX:DumpLoadedClassList=${classList.absolutePath}")
environment("MIKROMARKDOWN_NO_CDS", "1")
}
.standardOutput
.asText
.get()
check(classList.exists()) { "the JVM recorded no class list" }

val classpath =
script
.readLines()
.first { it.startsWith("CLASSPATH=") }
.removePrefix("CLASSPATH=")
.replace("\$APP_HOME", installDir.asFile.absolutePath)

providers
.exec {
commandLine(
javaHome.file("bin/java").asFile.absolutePath,
"-Xshare:dump",
"-XX:SharedClassListFile=${classList.absolutePath}",
"-XX:SharedArchiveFile=${archive.absolutePath}",
"-cp",
classpath,
)
}
.standardOutput
.asText
.get()
check(archive.exists()) { "no CDS archive was produced" }
logger.lifecycle("CDS archive: ${archive.length() / 1024} KB")
}
}

tasks.named("installDist") { finalizedBy(cdsArchive) }
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import io.github.lemcoder.mikromarkdown.converters.PlainTextConverter
import io.github.lemcoder.mikromarkdown.converters.PptxConverter
import io.github.lemcoder.mikromarkdown.converters.XlsxConverter
import io.github.lemcoder.mikromarkdown.converters.XmlConverter
import io.github.lemcoder.mikromarkdown.utils.AndroidMimeDetector
import java.io.File

/**
Expand All @@ -22,7 +21,7 @@ import java.io.File
* PDF support needs a [Context]: pdfbox-android loads its resources from the app's assets.
*/
public fun MikroMarkdown(context: Context? = null): MikroMarkdown =
MikroMarkdown(AndroidMimeDetector).apply {
MikroMarkdown(SignatureMimeDetector).apply {
register(MarkdownPassthroughConverter())
register(HtmlConverter())
register(CsvConverter())
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package io.github.lemcoder.mikromarkdown

import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readByteArray

/**
* Detects a format from the file's leading bytes, falling back to its extension.
*
* This is the default because it costs microseconds: it reads a handful of bytes and consults a fixed table, where a
* full MIME registry (Tika) spends ~90 ms building itself on first use — more than the entire conversion for most
* documents. Content still wins over the extension, so a mislabelled `.txt` that is really a PDF or an OOXML package is
* identified correctly.
*
* Formats that are plain text with no signature (CSV, JSON, XML, HTML, Markdown) are recognised by extension. Pass
* [TikaMimeDetector] to `MikroMarkdown` if you need content sniffing for those too.
*/
public object SignatureMimeDetector : MimeDetector {

private const val SIGNATURE_BYTES = 8

private val byExtension =
mapOf(
"csv" to "text/csv",
"docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"epub" to "application/epub+zip",
"htm" to "text/html",
"html" to "text/html",
"json" to "application/json",
"md" to "text/markdown",
"markdown" to "text/markdown",
"pdf" to "application/pdf",
"pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"txt" to "text/plain",
"log" to "text/plain",
"xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xml" to "application/xml",
)

/** ZIP-based formats are told apart by extension; the signature only proves it is a package. */
private val zipExtensions = setOf("docx", "xlsx", "pptx", "epub", "zip")

override fun detect(path: String): StreamInfo {
val filename = path.substringAfterLast('/').substringAfterLast('\\')
val extension = filename.substringAfterLast('.', "").lowercase().ifEmpty { null }
val signature = readSignature(path)

return StreamInfo(
mimetype = mimetypeOf(signature, extension),
extension = extension,
filename = filename,
localPath = path,
)
}

private fun mimetypeOf(signature: ByteArray, extension: String?): String? =
when {
signature.startsWith("%PDF") -> "application/pdf"
// Every OOXML container and EPUB is a ZIP; the extension says which one.
signature.startsWith("PK") ->
if (extension in zipExtensions) byExtension[extension] ?: "application/zip" else "application/zip"
// Legacy OLE compound files: .doc/.xls/.ppt, which no converter handles yet.
signature.startsWithBytes(0xD0, 0xCF, 0x11, 0xE0) -> "application/x-ole-storage"
else -> byExtension[extension]
}

private fun readSignature(path: String): ByteArray =
try {
SystemFileSystem.source(Path(path)).buffered().use { source ->
// Files shorter than the signature are read whole rather than failing.
if (source.request(SIGNATURE_BYTES.toLong())) source.readByteArray(SIGNATURE_BYTES)
else source.readByteArray()
}
} catch (_: Exception) {
ByteArray(0)
}

private fun ByteArray.startsWith(prefix: String): Boolean {
if (size < prefix.length) return false
return prefix.indices.all { this[it].toInt().toChar() == prefix[it] }
}

private fun ByteArray.startsWithBytes(vararg prefix: Int): Boolean {
if (size < prefix.size) return false
return prefix.indices.all { this[it].toInt() and 0xFF == prefix[it] }
}
}
Loading
Loading