Skip to content

Make ktfmt up to 100x faster via native-image#584

Open
sgammon wants to merge 4 commits intofacebook:mainfrom
sgammon:feat/native-image
Open

Make ktfmt up to 100x faster via native-image#584
sgammon wants to merge 4 commits intofacebook:mainfrom
sgammon:feat/native-image

Conversation

@sgammon
Copy link

@sgammon sgammon commented Jan 12, 2026

Native ktfmt

Introduces support for a native binary version of ktfmt, via GraalVM native-image; no code changes have taken place, just build changes, in order to make kotlinc's infrastructure (which ktfmt depends on) usable under SVM, Substrate Virtual Machine.

After some optimization and cleanup, I have some initial numbers which show promising gains:

image

The native version of ktfmt is identical in every way to the regular JVM version, except it is built AOT, and, since it is already machine code, it does not need a JVM installed at runtime.

We have been using this version of ktfmt internally for some time now -- a few weeks -- and it is stable for us even on codebases with hundreds or thousands of files. Once you hit many thousands of files, the performance gap between JVM and SVM begins to close (this is tunable). That makes sense, considering JVM's warmed-up JIT state is still known to beat SVM at peak performance.

However, for smaller executions of ktfmt -- particularly ones which only format changed code -- the difference can be quite persuasive toward native, because of essentially instant startup time.

Note: To actually ship this on ktfmt, we are going to need build infrastructure for each target OS/architecture pair. GraalVM's support matrix currently includes:

  • Linux amd64 / arm64
  • macOS amd64 / arm64
  • Windows amd64

I am able to build ktfmt for each of the above targets, and have CI (personally) to help, but will need to transition that stuff to GHA and Meta's control. In conversations with Nivaldo (@hick209), he indicated CI should be no problem.

Fixes and closes #441 cc / @ZacSweers

Trying it out

Here are pre-built binaries for Linux amd64 and macOS arm64, if you'd like to try it out. Each package comes with ktfmt and ktfmt-build-report.html, which is a standalone file you can open in your browser. It shows the full contents of the native image and various stats about its contents.

Download for macOS arm64
Download for Linux amd64

The final compressed packages end up at about 13MB for macOS and 16MB for Linux.

image

Introduces a `ktfmt` binary, built using GraalVM's
`native-image` tool. The binary behaves the same
way as `ktfmt`'s existing JVM-based CLI, but is
compiled ahead-of-time into a native executable.

Signed-off-by: Sam Gammon <sam@elide.dev>
Signed-off-by: Sam Gammon <sam@elide.dev>
@meta-cla
Copy link

meta-cla bot commented Jan 12, 2026

Hi @sgammon!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

Copy link
Author

@sgammon sgammon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-review

Comment on lines +2 to +4
### PGO Profiles

Run `pgo_train.sh` to generate `default.iprof` in this folder. Then, it will be used when `-Pktfmt.native.pgo=true` is passed to `./gradlew :ktfmt:nativeCompile`.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instructions to build and use PGO (Profile Guided Optimization) profiles are included, as well as profiles themselves. PGO significantly improves startup and peak performance

Comment on lines +3 to +12
{
"type": "org.jetbrains.kotlin.cli.common.CompilerSystemProperties"
},
{
"type": {
"proxy": [
"org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileListener"
]
}
},
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This metadata is assembled from runs using the Native Image reflection tracing agent; it should be held in source control because every code path should be tested to produce it, and it should evolve over time as the code underneath does, too

Comment on lines +194 to +265
// Pass `-Pktfmt.native.release=true` to enable release mode for Native Image.
val nativeRelease = findProperty("ktfmt.native.release") == "true"

// Pass `-Pktfmt.native.target=xx` to pass `-march=xx` to Native Image.
val nativeTarget =
findProperty("ktfmt.native.target")
?: when (val hostArch = System.getProperty("os.arch")) {
"amd64",
"x86_64" -> DefaultArchitectureTarget.amd64
"aarch64",
"arm64" -> DefaultArchitectureTarget.arm64
else -> error("Unrecognized host architecture: '$hostArch'")
}

// Pass `-Pktfmt.native.gc=xx` to select a garbage collector; options include `serial`, `G1`, and
// `epsilon`.
val nativeGc = findProperty("ktfmt.native.gc") ?: "G1"

// Pass `-Pktfmt.native.gc=xx` to select a garbage collector; options include `serial`, `G1`, and
// `epsilon`.
val nativeDebug = findProperty("ktfmt.native.debug") == "true"

// Pass `-Pktfmt.native.lto=true` to enable LTO for the Native Image binary.
val enableLto = findProperty("ktfmt.native.lto") == "true"

// Pass `-Pktfmt.native.muslHome=xx` or set MUSL_HOME to point to the Musl sysroot when building for
// Musl Libc.
val muslSysroot = (findProperty("ktfmt.native.muslHome") ?: System.getenv("MUSL_HOME"))?.toString()

// Pass `-Pktfmt.native.musl=true` to build a fully-static binary against Musl Libc.
val preferMusl =
(findProperty("ktfmt.native.musl") == "true").also { preferMusl ->
require(!preferMusl || muslSysroot != null) {
"When `ktfmt.native.musl` is true, -Pktfmt.native.muslHome or MUSL_HOME must be set to the Musl sysroot. " +
"See https://www.graalvm.org/latest/reference-manual/native-image/guides/build-static-executables/"
}
}

// Pass `-Pktfmt.native.smol=true` to build a small, instead of a fast, binary.
val preferSmol = (findProperty("ktfmt.native.smol") == "true")

// Pass `-Pktfmt.native.opt=s` to pass e.g. `-Os` to Native Image.
val nativeOpt =
when (val opt = findProperty("ktfmt.native.opt")) {
null ->
when {
preferSmol -> "s"
nativeRelease -> "3"
else -> "b" // prefer build speed
}
else -> opt
}

// List of PGO profiles, which are held in `src/main/native-image/profiles`.
val pgoProfiles =
listOf("default.iprof")
.map { profileName ->
layout.projectDirectory.file(
Paths.get("src", "main", "native-image", "profiles", profileName).toString()
)
}
.let { allProfiles -> listOf("--pgo=${allProfiles.joinToString(",")}") }

// Pass `-Pktfmt.native.pgo=true` to build with PGO; pass `train` to enable instrumentation.
val pgoArgs =
when (val pgo = findProperty("ktfmt.native.pgo")) {
null -> if (nativeRelease) pgoProfiles else emptyList()
"true" -> pgoProfiles
"false" -> emptyList()
"train" -> listOf("--pgo-instrument")
else -> error("Unrecognized `ktfmt.native.pgo` argument: '$pgo'")
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings which govern the Native Image build; many can be set with ./gradlew -P...

Comment on lines +27 to +28
# Native Image profiles are large
core/src/main/native-image/profiles/*.iprof
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently these are ignored, but they can be shipped up either in source control, or in git lfs. in an ideal case, PGO profiles would be materialized in CI as part of the release process, so they can never drift from underlying code. generally speaking, i generate PGO profiles for ktfmt by building with instrumentation and then formatting ktfmt itself.

Comment on lines +18 to +46
# clean any current native build
./gradlew :ktfmt:clean;

# build natively with PGO training on
./gradlew :ktfmt:nativeCompile \
-Pktfmt.native.pgo=train \
-Pktfmt.native.target=native \
-Pktfmt.native.lto=true;

echo "PGO training starting"

./core/build/native/nativeCompile/ktfmt -n $PWD

echo "PGO training completed."

cp -fv ./default.iprof ./core/src/main/native-image/profiles/default.iprof;

echo "Rebuilding..."

# clean for PGO-trained build
./gradlew :ktfmt:clean;

# rebuild
./gradlew :ktfmt:nativeCompile \
-Pktfmt.native.release=true \
-Pktfmt.native.lto=true \
-Pktfmt.native.pgo=true;

echo "PGO-trained build complete."
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full steps for pgo training, run bash pgo_train.sh to use it; this can be moved into a folder somewhere, but, because of the need for multiple gradle runs with distinct settings in each, it probably can't easily be ported into Gradle kts itself

@sgammon sgammon changed the title feat: ktfmt native image Make ktfmt up to 185x faster via native-image Jan 12, 2026
@sgammon sgammon changed the title Make ktfmt up to 185x faster via native-image Make ktfmt up to 100x faster via native-image Jan 12, 2026
@sgammon

This comment was marked as outdated.

@ZacSweers
Copy link
Contributor

I don't work at facebook and am not a maintainer on this project. I just filed that FR 🙃

@sgammon

This comment was marked as outdated.

@ZacSweers
Copy link
Contributor

yeah I meant to react to the PR description, accidentally did the wrong one 🙈

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jan 12, 2026
@meta-cla
Copy link

meta-cla bot commented Jan 12, 2026

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

Copy link
Contributor

@hick209 hick209 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial batch. There shall be more tomorrow 😃

Here are my initial comments. No need to act on anything just yet if you do not desire.

Comment on lines +30 to +37
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '25.0.1'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
set-java-home: 'false'
native-image-job-reports: 'true'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the CI part you said we need to work together to flush out or something else?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but really what i meant was, we will need CI systems for Windows and macOS, not just linux. i can easily set it up with a build matrix.

@@ -14,6 +14,7 @@
* limitations under the License.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I see a ton of changes here. Would it be possible to put them (at least a good chunk) into their own file so they do not "pollute as much" the build files? At the very least try to section them off somehow.

WDYT?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, i am happy to split it up a bit

Copy link
Contributor

@hick209 hick209 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's honestly a lot of things I don't really understand here.
From my limited understanding this seems like a good improvement and I'm tempted to just merge it in and give it a go.

Right now I see there some CI jobs (here from GH) failed, could you take a look to see if any of that makes sense before I pull this in for internal review?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: GraalVM artifacts

3 participants