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
115 changes: 114 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,17 @@ In-process, best of 50 runs after warmup:
| 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
End to end the CLI runs in 50–230 ms, against 3–5 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.

Note when reproducing this: anydoc's npm package is a Node script loading a napi module, so timing
`node_modules/.bin/anydoc` charges Node's 15 ms startup to Rust and reads as ~22 ms flat. The
figures here come from its Rust binary, built from the vendored source with
`cargo build --release --example convert`.

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)
Expand All @@ -193,6 +198,114 @@ The CLI therefore optimizes startup rather than throughput:
- 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.

### Kotlin/Native spike

`:cli-native` builds a macOS binary carrying the converters that need no JVM library — CSV, JSON,
XML, plain text and Markdown passthrough. It shares the model, renderer and pipeline with every
other target, and its output is byte-identical to the JVM CLI's.

```bash
./gradlew :cli-native:linkReleaseExecutableMacosArm64
```

Whole-process, best of eight, converting CSV of increasing size:

| input | Kotlin/Native | anydoc (Rust binary) | JVM CLI |
|---|---|---|---|
| 1 KB | 3 ms | 3 ms | 51 ms |
| 55 KB | **4 ms** | 5 ms | 61 ms |
| 172 KB | **7 ms** | 10 ms | 59 ms |
| 580 KB | **19 ms** | 25 ms | 70 ms |
| 1.8 MB | **51 ms** | 71 ms | 101 ms |
| 3.5 MB | **102 ms** | 139 ms | 142 ms |

Kotlin/Native is ahead of the Rust binary at every size here, having started the spike 18-21%
behind on large inputs; `docs/optimization-log.md` records how. Note that anydoc's npm package runs
through Node, which adds about 18 ms — these figures are its Rust binary, built from the vendored
source with `cargo build --release --example convert`.

On the document formats the native target does not carry, the JVM CLI converts a DOCX in 180 ms and
Wikipedia in 122 ms, against anydoc's 3 ms and Python markitdown's 409 ms and 520 ms. That gap is
process startup, not conversion: in-process those documents take 2.7 ms and 13 ms.

Three changes closed the throughput gap that the first cut of this target showed:

- **The renderer stopped allocating when it has nothing to change.** Escaping now scans for the
first character that needs a backslash and returns the input untouched when there is none, and a
single-line table cell skips the split-and-rejoin. Ordinary cells — a word, a number — now cost no
allocation at all. This is shared code, so the JVM got faster too.
- **The native CSV reader slices instead of accumulating.** Fields are ranges in the decoded text,
so a field costs one substring rather than a per-character builder plus a separate trim. Only
fields containing escaped quotes, which cannot be a slice of the input, assemble a string.
- **CSV, JSON and XML moved to `commonMain`**, taking commons-csv, Jackson and
kotlinx-serialization with them. One implementation now serves every target: a slicing CSV reader,
a JSON re-indenter that copies tokens verbatim so `1.50` does not become `1.5`, and an XML
formatter. A JSON conversion loads 1214 classes instead of 2063, the native binary is 1.3 MB
instead of 2.2 MB, and the JVM CLI converts JSON in 56 ms instead of 95 ms. Output is unchanged
on every fixture.
- **Two quadratics in the renderer are gone.** Blocks are written into one buffer carrying a line
prefix, rather than each block returning a string that its parent splits into lines and re-joins —
which charged the deepest content once per level of nesting above it. And the entity check now
scans ten characters ahead instead of searching the rest of the document for a semicolon.
Measured on inputs built to provoke them, with output byte-identical before and after:

| pathological input | before | after |
|---|---|---|
| 400-deep nested lists | 33.03 ms | **0.12 ms** |
| 400 ampersands per cell, 789 KB | 9.39 ms | **2.42 ms** |

Together those took a 1.8 MB CSV from 237 ms to 87 ms. Kotlin/Native's remaining cost is the
document model itself: every cell becomes a `TableCell` holding a `Text` holding a `String`, which
is why peak memory is 125 MB for a 1.8 MB input. A genuinely zero-copy model — inlines holding
slices of the source buffer rather than copies — is the next lever, and a deeper change.

Compiler flags were measured rather than guessed. `-Xbinary=preCodegenInlineThreshold=40` is worth
about 8% on large inputs and ships. Every garbage collection setting tried was worse than the
default, and the collector is the interesting part of the story, so the numbers are below.

Converting 20 documents of 580 KB in one process:

| policy | time | peak RSS |
|---|---|---|
| default (adaptive) | 543 ms | 59 MB |
| `gcSchedulerType=manual`, never collecting | 428 ms | 954 MB |
| `gcSchedulerType=manual`, collecting between documents | 485 ms | 67 MB |
| `autotune = false` with a heap ceiling | 1246 ms | 43 MB |

A manual collector is genuinely faster, since a process that exits never needs to collect, and a
document boundary is the one place where everything the previous conversion allocated is provably
dead. But it only bounds growth *between* documents: a single large input still has nothing
collecting mid-parse, so the 3.5 MB file takes 228 MB either way and a much larger one would grow
until it failed. Turning `autotune` off is far worse than it looks like it should be — 8.6x on a
single 3.5 MB file — and the ceiling value makes no difference to that, so `targetHeapBytes` is not
behaving as its name suggests.

The default collector ships. The native CLI does accept several files per invocation, which is what
would make a manual policy workable if the trade ever becomes worth it.

### What did not help

Binary size does not drive startup, so shrinking it is not a performance lever:

| binary | size | startup |
|---|---|---|
| Kotlin/Native hello world | 485 KB | 3.2 ms |
| this CLI | 1.3 MB | 3.5 ms |
| anydoc (Rust) | 6 MB | 2.6 ms |

A 6 MB Rust binary starts faster than a 485 KB Kotlin/Native one, and stripping ours changed
nothing measurable. The 0.6 ms between the two runtimes is initialization, not size.

Replacing clikt with hand-rolled argument parsing removes 147 loaded classes and about 2 ms — inside
the noise, and not worth losing its help output and error handling. The dependency stays.

Zero-copy parsing made things slower, which is the most useful negative result here. Handing the
renderer a `CharSequence` window onto the source instead of a substring removes one allocation per
cell, but every character access then goes through an interface call rather than `String`'s direct
indexing — and the renderer reads every character anyway to decide whether it needs escaping. A
1.8 MB CSV went from 86 ms to 118 ms on native and 126 ms to 154 ms on the JVM. Rust gets this for
free because `&str` slices index directly; Kotlin does not. Reverted.

## Benchmark

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

kotlin {
macosArm64 {
binaries.executable { entryPoint = "io.github.lemcoder.mikromarkdown.cli.main" }

compilerOptions {
// Worth about 8% on large inputs and nothing on small ones. Measured, not assumed:
// the GC binary options were all neutral or worse, so the defaults stay.
//
// gcSchedulerType=manual is the one real alternative — 20% faster on a 1.8 MB CSV,
// because a process that exits never needs to collect — but peak memory goes from
// 125 MB to 169 MB on that input, and it grows without bound on larger ones.
freeCompilerArgs.add("-Xbinary=preCodegenInlineThreshold=40")
}
}

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

import io.github.lemcoder.mikromarkdown.MikroMarkdown
import io.github.lemcoder.mikromarkdown.MikroMarkdownException
import kotlin.system.exitProcess

/**
* Minimal native entry point, kept deliberately bare so its timings measure conversion rather than an argument parser.
* The JVM CLI remains the full one.
*
* Several files may be given: a document boundary is the one point where everything the previous conversion allocated
* is dead, which is what makes a manual collection policy possible at all. The default collector wins on measurement,
* so none is applied — see the README.
*/
public fun main(args: Array<String>) {
if (args.isEmpty()) {
println("usage: mikromarkdown <file>...")
exitProcess(2)
}

val mikroMarkdown = MikroMarkdown()

for (path in args) {
try {
print(mikroMarkdown.convert(path).markdown)
} catch (e: MikroMarkdownException) {
// Unsupported formats must fail loudly: benchmarks and scripts read the exit code.
println("error: ${e.message}")
exitProcess(1)
}
}
}
29 changes: 19 additions & 10 deletions cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ val cdsArchive by tasks.registering {

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 fixtures = rootProject.layout.projectDirectory.dir("library/src/commonTest/resources/test_files")
// One sample per family, so the archive covers the classes each conversion path touches rather
// than only the ones a DOCX happens to need.
val samples =
listOf("test.docx", "test.pdf", "test_blog.html", "test.csv", "test.json", "test.xlsx").map {
fixtures.file(it).asFile.absolutePath
}
val javaHome = javaToolchains.launcherFor(java.toolchain).get().metadata.installationPath

doLast {
Expand All @@ -85,15 +91,18 @@ val cdsArchive by tasks.registering {
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()
// One run over one sample of each family: a single list keeps the loader metadata that
// makes the archive worth having, which merging separate runs would throw away.
val process =
ProcessBuilder(listOf(script.absolutePath) + samples)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.also {
it.environment()["JAVA_OPTS"] = "-XX:DumpLoadedClassList=${classList.absolutePath}"
it.environment()["MIKROMARKDOWN_NO_CDS"] = "1"
}
.start()
val errors = process.errorStream.bufferedReader().readText()
check(process.waitFor() == 0) { "recording failed: ${errors.take(400)}" }
check(classList.exists()) { "the JVM recorded no class list" }

val classpath =
Expand Down
21 changes: 9 additions & 12 deletions cli/src/main/kotlin/com/mikromarkdown/cli/MikroMarkdownCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,30 @@ package com.mikromarkdown.cli

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.optional
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.path
import io.github.lemcoder.mikromarkdown.MikroMarkdown
import io.github.lemcoder.mikromarkdown.StreamInfo

class MikroMarkdownCommand : CliktCommand(name = "mikromarkdown") {
private val file by argument("FILE", help = "Input file (reads stdin if omitted)").path(mustExist = true).optional()
private val files by
argument("FILE", help = "Input files (reads stdin if omitted)").path(mustExist = true).multiple()
private val output by option("-o", "--output", help = "Output file (default: stdout)").path()
private val extension by option("-x", "--extension", help = "File extension hint (e.g. html)")
private val mimeType by option("-m", "--mime-type", help = "MIME type hint (e.g. text/html)")

override fun run() {
val mikroMarkdown = MikroMarkdown()

val result =
if (file != null) {
mikroMarkdown.convert(file!!.toFile().absolutePath)
} else {
val markdown =
if (files.isEmpty()) {
val info = StreamInfo(extension = extension, mimetype = mimeType)
mikroMarkdown.convert(System.`in`.readBytes(), info)
mikroMarkdown.convert(System.`in`.readBytes(), info).markdown
} else {
files.joinToString("\n\n") { mikroMarkdown.convert(it.toFile().absolutePath).markdown }
}

if (output != null) {
output!!.toFile().writeText(result.markdown)
} else {
print(result.markdown)
}
if (output != null) output!!.toFile().writeText(markdown) else print(markdown)
}
}
Loading