This Maven plugin may optimize your Bytecode after compilation,
to make it work faster.
Just add this to your pom.xml file (you must have Docker installed too):
<project>
[..]
<build>
<plugins>
<plugin>
<groupId>org.eolang</groupId>
<artifactId>hone-maven-plugin</artifactId>
<version>0.29.4</version>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>optimize</goal>
<goal>rmi</goal>
</goals>
<configuration>
<rules>streams/*</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>The plugin will do exactly the following:
- Take Bytecode
.classfiles from thetarget/classes/directory and copy all of them to thetarget/classes-before-hone/directory (as a backup). - Using jeo-maven-plugin,
transform
.classfiles to.xmirformat, which is EO in XML, and place them into thetarget/hone/jeo-disassemble/directory. - Using phino,
convert
.xmirfiles to.phifiles with π-calculus expressions, and place them into thetarget/hone/phi/directory. - Using phino,
apply a number of optimizations to π-calculus expressions in the
.phifiles and place new.phifiles into thetarget/hone/phi-optimized/directory. - Using phino,
convert
.phifiles back to.xmirfiles and place them into thetarget/hone/unphi/directory. - Using jeo-maven-plugin,
transform
.xmirfiles back to Bytecode and place.classfiles into thetarget/classes/directory.
The effect of the plugin should be performance-positive (your code should work faster) along with no functionality degradation (your code should work exactly the same as it worked before optimizations). If any of these is not true, submit a ticket, we will try to fix.
To make it work faster, you may install phino on your machine beforehand.
The most interesting step of the pipeline is the phi-to-phi rewriting,
where phino turns one π-calculus expression into another
by applying a fixed set of rules to every .phi file
in target/hone/phi/ and writing the result into target/hone/phi-optimized/.
Each rule lives in its own .phr file under
src/main/resources/org/eolang/hone/rules/
and has the same three-part shape:
pattern describes the sub-expression to look for,
result describes what to put in its place,
and an optional where block defines auxiliary metavariables
computed by small string functions such as concat, sed, join, and tau.
Inside the π-calculus body,
identifiers prefixed with π΅- capture whole groups of bindings,
those prefixed with π- capture a single binding name,
and those prefixed with π- capture an atomic sub-expression,
so that a single rule can match an entire family of concrete expressions.
When the plugin is configured with <rules>streams/*</rules>,
the Rules class scans the classpath under that directory,
collects every .phr (and .yml) file that matches the pattern,
and sorts the resulting names alphabetically before passing them on
(see Collections.sort(names) in Rules.discover()).
Each filename begins with a numeric prefix
(for example 101-, 111-, 121-, ..., 701-, 702-),
so the alphabetical sort produces the exact sequence
in which the rules are intended to fire,
one by one.
The prefixes also reveal the logical phases of the pipeline:
the 1xx group prepares the input
(it removes self-referencing labels and lowers invokedynamic to a lambda),
the 2xx group recognises stream operations
(filter, map, their primitive variants, unbox and box),
the 3xx group folds every recognised operation
into a uniform internal distill node,
the 4xx group fuses adjacent distill nodes
into a single combined one,
the 5xx group rewrites the fused chain into one mapMulti call,
the 6xx group lowers mapMulti back into a lambda,
and the 7xx group re-introduces invokedynamic
so that the bytecode emitted by JEO is shaped the way the JVM expects.
In the default "big-steps" mode,
the rewrite.sh script invokes phino rewrite once per .phi file
and passes every selected rule on the same command line
as a sequence of --rule= arguments in their alphabetical order.
Phino itself then walks through that list,
trying each rule against every position in the expression tree
and re-trying until no rule matches anymore,
capped by the maxDepth and maxCycles parameters.
The "small-steps" mode (<smallSteps>true</smallSteps>)
is meant for debugging the rules:
it invokes phino rewrite separately for each rule
and saves the intermediate result as Foo.phi.001, Foo.phi.002, and so on,
with the suffix padded to the number of digits in the rule count,
so that a diff between two adjacent files
reveals exactly which rule changed what.
When jeo-maven-plugin assembles the rewritten .class files back,
it verifies each of them against the JVM specification,
so that a rule that produced unloadable bytecode
fails the build that made it
instead of the runtime of the user.
The check costs build time,
which <skipVerification>true</skipVerification> buys back,
at the price of a VerifyError arriving much later.
The same switch is the way out
when the verifier stops on a type it cannot resolve:
jeo knows only the classes it assembles,
so a method that touches a type from a dependency
fails with a ClassNotFoundException
instead of a verdict on the bytecode
(see jeo#1763).
The .phi file that arrives from jeo-maven-plugin
contains the bytecode of one Java class
re-encoded as a single π-expression:
every instruction (iload, imul, invokedynamic, ...)
becomes a small formation
whose Ο attribute dispatches to a name under Ξ¦.jeo.opcode,
and the metadata of the class
(its name, access flags, methods, line numbers, ...)
sits alongside as ordinary bindings.
The rules in streams/ then walk this expression through
seven stages of rewriting,
turning a sequence of low-level instructions
into a single fused stream operation
while keeping the resulting expression
re-assemblable back to valid .class bytes by jeo.
The pipeline below mirrors the one described in the hone paper.
Stage 1 (rules 101- to 141-): clean up and lower invokedynamic.
The Java compiler emits a stream pipeline as a chain of
invokedynamic+invokeinterface pairs
plus a synthetic static method per lambda;
before anything else can be recognised,
the noise around those instructions must go.
Rule 101-remove-self-reference-labels deletes labels and line-number
entries that no longer point anywhere meaningful,
while 151-remove-line-numbers drops every line-number entry of the class,
the methods with no pipeline in them included,
so a class the plugin touched carries no LineNumberTable
and its stack traces name no lines;
exclude the rule with streams/*,!streams/1xx/151-* to keep them,
at the price of fusing fewer pipelines,
111-invokedynamic-to-lambda rewrites the invokedynamic formation
into a higher-level Ξ¦.hone.lambda pragma that exposes
the interface, the target method, and the captured arguments,
120- and 121- normalise the static-vs-instance shape
of the produced lambda,
and 141-set-opcode-in-lambda records the original opcode
on the pragma so that later stages can reverse the lowering.
A lambda that CAPTURES something has a non-empty factory descriptor,
which 111- declines,
so each capturing operator gets a lifting rule of its own β
112- for map, 113-capturing-invokedynamic-to-filter for filter,
and 113-capturing-invokedynamic-to-dropwhile for dropWhile β
paired with a 11x- rule that peels the captured pushes
into the shared state list the fused body reads them back from.
Stage 2 (rules 201- to 282-): recognise stream operations as pragmas.
A Ξ¦.hone.lambda immediately followed by an invokeinterface
on a java.util.stream class is, semantically, one operation:
rules 201-lambda-to-filter, 202-lambda-to-map,
203-lambda-to-primitive-filter, 204-lambda-to-primitive-map,
and 205-lambda-to-unbox
match exactly that pair and replace it with a single
Ξ¦.hone.filter / Ξ¦.hone.map / Ξ¦.hone.unbox pragma.
The paper calls these synthetic formations pragmas:
they look like bytecode instructions
but carry the information needed to reconstruct one later.
Two operations carry no lambda at all β
distinct() and skip(n) β
so there is no pair to match:
rules 216-recognize-distinct and 220-recognize-skip
lift the bare call instead,
and 212-recognize-primitive-distinct and
212-recognize-primitive-skip lift the IntStream ones (#996).
Because those two carry no signature to read an element type off,
211-box-unbox-primitive-function cannot wrap them
the way it wraps every other primitive operation,
so 214-box-unbox-primitive-stateful builds
the boxed() / mapToInt sandwich around them from the stream
interface instead,
and the object folds take it from there unchanged.
An operation whose argument is an OBJECT rather than a lambda
carries no invokedynamic either:
.map(new Foo()) compiles to
new / dup / invokespecial / invokeinterface,
so 111- never fires
and nothing downstream could see the operation at all (#635).
Rules 215-recognize-named-map and 215-recognize-named-filter
lift that run the way 216- lifts a bare distinct,
with the constructor's own pushes bound whole when it captures (#1008).
A capturing constructor is taken whatever its arguments weigh (#1029).
A constructor whose arguments branch, like new P(flag ? 1 : 0),
is left native by 215-decline-named-with-frames:
the frames of the branch describe javac's stack,
not the one of the state append (#1225).
The run being lifted is javac's own
and stands at the depth javac sized max_stack for,
so relocating it into 502's state append
costs the two cells of the ArrayList beneath it and nothing else β
the arguments cancel.
The fixed bump of 6 that 502 applies
is sized for 307's and 308's appends,
which materialise a HashSet and a long[]
that were never in the original bytecode,
and it covers a relocated append several times over.
An operator that is LOADED rather than built β
out of a parameter, a local, or a static field β
has no such run to anchor on,
and the lone aload in front of the call
cannot be told apart from the receiver stream's own,
so 215-recognize-ref-map and 215-recognize-ref-filter
key on the call's own descriptor instead,
which names the functional interface,
and take the opcode adjacent to it β
which a one-argument call site guarantees is the argument (#1014).
Both shapes mint the same pragma,
differing only in what its make block holds,
and because Function.apply is erased β
the call site javac emits says only java.lang.Object β
247- reads the element type off the pragma that consumes it
and 317- narrows the item there with a single CHECKCAST.
One operation gets no pragma of its own:
IntStream.mapToObj(...) crosses from a primitive stream
to a reference one,
and there is no IntStream.mapMultiToObj for stage 5 to emit.
Rule 208-mapToObj-to-boxed-map moves the crossing instead,
rewriting it into two pragmas that already exist β
the Ξ¦.hone.box of a boxed()
and a plain Ξ¦.hone.map carrying the original mapper β
which puts the operation on the reference side of the crossing,
where everything downstream can fuse it.
The split is a bet on that fusion,
since the boxed() is a stream stage the original mapToObj did not have,
so when nothing fuses into the map
rule 452-unfused-crossing-to-mapToObj takes the bet back
and hands the operation over to stage 7 as the mapToObj it was (#1105).
Rules 206- through 261- then tidy up the boxing and primitive
conversions that the compiler inserted around the lambda
(for example moving an Integer.valueOf call from outside the lambda
into a Ξ¦.hone.box pragma),
splicing a Wrapper.xValue() wherever one pragma leaves a reference
and the next one wants the primitive
(241- to 246-, where 245- and 246- are the two sides
of the seam a lifted primitive distinct or skip opens,
since neither pragma carries a type to read),
and 271-, 272- remove the now-useless CHECKCAST and
object-to-primitive conversions that the pragma made redundant.
Rules 281- and 282- insert a DUP in front of every filter
so the value can be both tested and forwarded
without re-running the predicate,
and 283- does the same behind an operator that carries state
(a distinct, a skip, a dropWhile, a capturing one,
or a map over an object argument).
Stage 3 (rules 301- to 311-): fold every operation into distill.
Mapping and filtering still look different at this point:
map rewrites a value, filter drops one.
The third stage erases that difference
by rewriting every Ξ¦.hone.map, Ξ¦.hone.filter,
and their primitive variants
into a uniform Ξ¦.hone.distill pragma
whose body is a piece of bytecode that accepts an item
and either falls through (keep), returns early (drop),
or replaces the local variable (transform).
Rules 301-dup-to-distill, 302-transform-to-distill,
303-type-to-distill, 304-primitive-filter-to-distill,
305-object-filter-to-distill, and 306-map-to-distill
handle the individual cases,
and 311-load-this-in-pre-distill prepares the stack
for instance-method lambdas.
After this stage,
a pipeline of .map().filter().mapToInt()...
has been reduced to a flat list of distill pragmas
inside the same method.
Stage 4 (rules 401- to 461-): fuse adjacent distill pragmas.
This is the optimization that actually saves work at runtime.
Rule 401-fuse looks for two consecutive distill pragmas
inside the same method body
and concatenates their lambda bodies into a single distill,
so that the JVM has to traverse the stream pipeline only once
instead of pumping every element through several Consumer objects.
The rule fires repeatedly under phino's fixed-point evaluation:
if a method had five chained operations,
401-fuse will fire four times,
collapsing all of them into one.
Concatenation only works when the type that flows between
two pragmas is the same;
when one pragma returns a primitive and the next expects a wrapper
(or vice versa),
rule 411-box-distill-unbox-to-primitive-distill
unifies the boundary
by pushing the boxing/unboxing inside the body,
and 421-/422- align the head and tail types
with the surrounding bytecode.
Rule 413-dup-before-filter-distill re-applies the DUP
fix-up for the filter that opens a fused run,
which has no predecessor for 281- to splice a guard behind;
it deliberately runs ahead of 421-,
so that a head unboxing lands in front of the guard
and the copy the predicate eats is the primitive it expects.
A distill that nothing fused into is not worth lowering:
as a mapMulti it would cost a wrapper call and a sink
the original operation did not have.
Rules 441- and 442- put a lone distinct and a lone skip back,
452- a lone mapToObj crossing,
and 453-unfused-map-to-lambda with its primitive sibling
a lone map, whose body is still the single call to its lambda,
so stage 7 emits it as the map it was (#1128).
Stage 5 (rules 501- and 511-): emit a single mapMulti call.
At this point each fused distill is one big anonymous function
that consumes an item.
The JDK already offers an idiomatic shape for exactly that,
Stream.mapMulti(BiConsumer),
whose second argument is the downstream sink.
Rule 501-distill-to-mapMulti rewrites the distill pragma
into a Ξ¦.hone.mapMulti pragma
by appending a c.accept(x) call to the body
(where c is the BiConsumer argument),
and 511-distill-lambda-to-method extracts the body
into a real private static method on the class
so that it can be invoked through a method handle.
Stage 6 (rules 601- to 603-): pragmas back to lambdas and invokeinterface.
Once the optimizer has done its job,
the Ξ¦.hone.* pragmas have to disappear:
jeo doesn't know about them and can't translate them to bytecode.
Rule 601-mapMulti-to-lambda rewrites the Ξ¦.hone.mapMulti pragma
back into a Ξ¦.hone.lambda formation paired with an invokeinterface
call on Stream.mapMulti,
reversing the recognition that stages 2 and 5 performed.
Rule 602-box-to-boxed rewrites the Ξ¦.hone.box pragma
directly into an invokeinterface on Integer.valueOf (and its siblings),
since boxing is just a static method call and does not need a lambda.
Rule 603-unbox-to-lambda rewrites the Ξ¦.hone.unbox pragma
back into a Ξ¦.hone.lambda formation,
matching the shape that 205-lambda-to-unbox originally consumed.
After this stage, every pragma is gone;
what remains are Ξ¦.hone.lambda formations and ordinary bytecode opcodes.
Stage 7 (rules 701- and 702-): lambdas back to invokedynamic.
This stage is the inverse of stage 1 and the final bytecode-level fixup.
Rule 701-static-lambda-to-invokedynamic handles
the lambdas whose body is a static method,
and 702-nonstatic-lambda-to-invokedynamic
handles the ones that capture a this reference.
Both rules lower a Ξ¦.hone.lambda formation
into a full Ξ¦.jeo.opcode.invokedynamic formation,
reconstructing the LambdaMetafactory.metafactory call site,
the bridge and target method handles,
and the method-type descriptors
that the JVM expects to find on the constant pool side of an invokedynamic.
The result is a .phi file
that contains regular bytecode instructions again,
just fewer of them and arranged for a single pass through the stream,
ready to be converted back to .xmir by phino
and then to .class by jeo.
This pipeline never invents bytecode that the JVM cannot run.
Even when the fusion stage produces a method body
that the Java compiler would reject
(for example one local variable holding values of two unrelated
reference types in succession),
the JVM itself does not type-check locals at runtime,
so the resulting .class file still verifies and executes.
The hone paper discusses this property,
along with the cases (mixing primitives and wrappers across a fuse)
where an explicit boxing distill must be inserted to keep the JVM happy.
The pipeline deliberately fuses only non-short-circuiting operations
(filter, map, peek, distinct, skip, dropWhile, flatMap,
the primitive conversions, and mapMulti itself);
the two short-circuiting intermediate operations of the JDK,
limit() and takeWhile(), are left untouched.
The reason is structural:
everything the fusion produces is one Stream.mapMulti(BiConsumer) stage,
and a mapMulti body may keep, drop, or transform an element,
but it has no access to the Sink.cancellationRequested() channel β
there is no way for the fused body to ask upstream to stop traversal.
A stateful guard inside the body
(a countdown for limit, exactly like the one 308-skip-to-distill
builds for skip but with the branch inverted,
or a sticky latch for takeWhile)
would reproduce the values of the original pipeline
but not its traversal contract:
the source keeps being pulled after the cut-off.
On an unbounded source such as Stream.iterate(1, n -> n + 2).limit(5)
the rewritten program would simply never terminate,
and on a bounded one it would traverse the entire source β
list.stream().limit(3) would visit every element of the list β
pessimizing the very behavior limit exists to provide.
A semantics-preserving variant
(keep the native Stream.limit(n) right after the fused mapMulti
so that upstream cancellation still happens)
is only exact when no element-dropping operation is fused after the guard,
and would save a single invokedynamic in that narrow shape β
not worth the rule complexity.
Instead, limit and takeWhile act as barriers:
the chains of operations on either side of them
fuse into their own mapMulti calls independently,
while the native short-circuiting call stays in place.
The same blind spot reaches flatMap from the other direction (#736).
A flatMap is fusable β 455-flatMap-to-mapMulti and its primitive siblings
rewrite it into a mapMulti whose adapter drains each inner stream with
result.sequential().forEach(c) β but that adapter, being just a mapMulti
body, cannot see cancellationRequested() either.
The JDK's ReferencePipeline.flatMap does forward cancellation:
once a downstream short-circuit has fired it stops part-way through an inner
stream, so Stream.of(1L).flatMap(x -> Stream.iterate(x, i -> i + 1)).limit(5)
yields five elements and terminates; the fused forEach adapter, by contrast,
runs to completion for every source element and hangs forever on the
unbounded inner stream.
Because a mapMulti cannot honour cancellation, the cure mirrors the
revert-on-parallel guard above rather than patching the adapter:
454-flatMap-revert-on-short-circuit reverts the lifted flatMap back to its
native call whenever the same method body reaches a limit, takeWhile,
findFirst, findAny, anyMatch, allMatch, noneMatch or gather
downstream of it, so those flatMaps stay unfused.
Covering those eight names takes two rules, because by the time the 4xx pass
runs they no longer share a shape (#816):
limit, findFirst, findAny and gather take no lambda and are still
plain opcodes,
while takeWhile, anyMatch, allMatch and noneMatch each take one and
have therefore been folded into a Ξ¦.hone.lambda by
111-invokedynamic-to-lambda.
454-flatMap-revert-on-short-circuit matches the opcode shape and its sibling
454-flatMap-revert-on-lifted-short-circuit the folded one, with the same
guard and the same eight-name list in both;
until the second one existed the four lambda-taking names went unguarded, the
flatMap was fused anyway, and the hang above stayed reachable through
.takeWhile(i -> i < 5).
gather joined the list last (#972).
A Gatherer whose integrator returns false stops the upstream
and the JDK honours that through flatMap exactly as it honours a limit,
so the same pipeline hangs when fused;
the operator was still a preview API when the list was drawn up
and reached final API status in Java 24.
It is the one name the end-to-end fixtures cannot reach,
because they are compiled at maven.compiler.release=17 β
the two single-rule packs are what keep it on the list.
skip(n) is fused (it lowers to a countdown counter inside the
mapMulti body, see 308-skip-to-distill), but only when the pipeline
provably stays sequential.
The fused counter is correct only if arrival order equals encounter order:
on a parallel stream several ForkJoin workers drive the one captured
counter concurrently, so it both races and counts in arrival order rather
than encounter order, dropping the wrong elements
(this is the order-dependent analogue of the parallel distinct race
fixed in #715, except that no thread-safe data structure can recover
encounter order once it is lost).
The JDK's native Stream.skip(n) honours the ordered/parallel contract,
so when a method contains a parallel(), parallelStream() or
StreamSupport.stream() call the
rules 222-parallel-reverts-skip and 223-parallel-reverts-skip-after
revert the recognised skip back to the native call before 308 can fold
it (#719, #717).
The same revert-on-parallel guard applies to dropWhile, which is
order-dependent for the same reason: the rules
226-parallel-reverts-dropwhile and 227-parallel-reverts-dropwhile-after
revert the recognised dropWhile (rebuilding its predicate lambda) back to
the native call before 310 can fold it (#862).
distinct() is fused (it lowers to a seen-set check inside the mapMulti
body, see 307-distinct-to-distill), but only when the pipeline provably
stays sequential.
The fused seen-set is a plain java.util.HashSet, and the fold that builds
it (307-distinct-to-distill) is reached only on a pipeline that stays on
one thread; the one fold that can run wide builds a
Collections.synchronizedSet(new HashSet<>()) instead, which is what #715
asked for when it closed the data race on parallel streams.
Making add() atomic, however, only fixes whether duplicates leak, not
which element survives among equals.
The JDK's distinct() is documented stable on an ordered stream: among
equal elements it keeps the one first in encounter order.
A single shared set populated by several ForkJoin workers instead keeps
whichever equal element wins the race to add() β arrival-order-first,
which is neither encounter-order-first nor deterministic across runs.
For primitives and substitutable-equals values this is unobservable, but
for equal-but-distinguishable objects (an equals/hashCode that collapses
instances differing in a payload field) the surviving object changes from
run to run, silently violating the stability contract.
The JDK's native Stream.distinct() honours the ordered/parallel contract,
so β exactly as for skip β when a method contains a parallel() or
parallelStream() call the rules 224-parallel-reverts-distinct and
225-parallel-reverts-distinct-after revert the recognised distinct back
to the native call before 307 can fold it (#738).
That stability contract, however, holds only while the stream is ordered,
and BaseStream.unordered() is exactly the call that drops it:
an unordered distinct() is specified to keep any element among equals,
which is precisely what a single shared seen-set already delivers.
So 217-unordered-permits-distinct gives the fusion back
when an explicit unordered() call precedes the distinct(),
by stamping the pragma with a mark that 224 and 225 decline (#975):
// reverted: parallel and ordered, so stability applies
WORDS.stream().parallel().map(s -> s + "!").distinct()
// fused: unordered before the distinct, so any survivor is legal
WORDS.stream().unordered().parallel().map(s -> s + "!").distinct()
// reverted: unordered() marks the stream unordered from that point ON,
// so a distinct ahead of it still ran on an ordered stream
WORDS.stream().parallel().map(s -> s + "!").distinct().unordered()Whichever of the two folds fires, the set has to accept null, because
distinct() does:
the JDK runs the sequential ordered case through a LinkedHashSet,
which holds one null happily.
Both folds used to build the set with ConcurrentHashMap.newKeySet(),
whose add() throws NullPointerException on null β
and it threw from inside the synthetic distill_β¦ lambda,
so a pipeline that carried nulls before the rewrite died after it
with a stack trace pointing nowhere near the user's code (#970):
// 2 before the rewrite, NullPointerException after it
Stream.of("a", null, "a", null).distinct().map(s -> s).count();HashSet restores that behaviour and costs less per element,
and Collections.synchronizedSet keeps it on the unordered fold,
which is the only one that pays for a lock.
Nothing stateful fused on a LongStream or a DoubleStream until #1012,
and the reason was a local-variable layout rather than a contract.
512 lowers a stateful distill into a wrapper
whose locals it writes out itself:
the captured List at 0, the item at 1, the consumer at 2,
521's fetch counter at 3,
and 310's dropWhile latch scratch at 4 and 5.
A long or a double takes two slots,
so a wide item slides every one of those by one,
and 521 β which sees a bare fetch marker β
had no way to learn the width of the item
in the method it was lowering into.
So distinct(), skip() and dropWhile() all stayed native
on half the primitive stream types,
which is the whole stateful family.
Issues #982 and #996 each closed the IntStream arm of that gap
and left the rest,
so the backlog looked like it tracked something it did not.
All three fuse on a wide element now.
distinct() and skip() needed one more thing than the layout,
and it was #1016 rather than anything about element width:
their lift wraps the operation in a boxing sandwich,
and 221 used to cancel that sandwich's trailing unbox
against whatever box came next β
including the user's own boxed(),
whose boxing then simply disappeared,
handing a mapMultiToInt's IntStream
to code holding the Stream that boxed() promised.
Four rules mint a box and they are not interchangeable,
so every box now records which one minted it:
minted β¦ "user" for a boxed() the user wrote,
"crossing" for 208-mapToObj's primitive-to-reference step,
"split" for 211's sandwich head
and "sandwich" for 214's.
221-unbox-box-to-map takes every box but the user's own,
and 209-unbox-box-to-distill takes only the user's and the crossing,
which is what its header always said it wanted
and what its position in the sort never actually gave it β
a rule cannot lean on its position:
with hone.max-cycles above its default of one
the set keeps walking and 209 comes round again
after 211 and 214 have fired,
which is why the guard is the minted binding and not the sort order.
The one user boxed() that is still safe to cancel against
is one the user's own mapToX unboxes on the very next step,
and 221-unbox-user-box-to-map takes that one,
pinning the following unbox in its pattern
so the round trip it drops is provably dead.
That defect was live on IntStream long before any wide element
could reach it.
Two numbers move with the item's width,
and both are now derived from it rather than written out:
the consumer's slot and the counter's.
512 reads the width off bridge-input,
and 521 reads it off the parameter 512 declared,
which is the same fact from the same place.
The latch scratch does not move at all β
it is pinned above the widest counter,
and it is dead by the guard's keep-label,
so no frame ever lists it and the slot below it may go unwritten.
Every frame in the family stays a four-local frame either way,
because a stackmap frame lists one entry per value and not per slot.
One more thing has to follow the width.
A filter, a peek and a dropWhile each copy the item with a dup
so the predicate can eat the copy and the original can travel on,
and a two-slot value needs a dup2.
The rules that splice those copies cannot tell:
when they fire the operation is still inside the boxing sandwich,
where the item is the one-slot wrapper,
and 411 only afterwards collapses the sandwich onto the raw primitive.
414-widen-guard-dup makes that repair once, after 411 has decided,
and it decides from what the copy is handed to β
a one-parameter (J)Z predicate copies a long β
rather than from the distill's element type,
which is still a reference when a distinct() leads the run.
skip() and dropWhile() have unordered contracts of their own
β any n elements, any subset of the matching prefix β
so the same relaxation would extend to 222/223 and 226/227,
but only once their state is atomic:
a countdown long[1] and a sticky int[1] are raced on a parallel stream,
not merely unstable, so those two stay reverted on any parallel pipeline.
ReferencePipeline.count() is allowed to skip the pipeline altogether
when the source is SIZED and nothing clears it,
and it does β it just asks the spliterator for its size.
So under a bare count() the lambdas never run:
// prints nothing: the map is never executed
Stream.of("a", "bb").map(s -> { log(s); return s.length(); }).count();mapMulti clears SIZED by design,
so a fused chain forces the traversal the JVM was going to skip,
and every map and peek lambda starts running.
An exception thrown inside one goes the same way:
invisible before the rewrite, thrown after it.
Both forms are within the specification,
since count() is documented as possibly not executing the pipeline,
so this is a question of what the plugin promises.
It promises not to add work:
101-mark-traversing-count and
142-count-keeps-elidable-stage-native keep the chain native
whenever the terminal is one the JVM can elide (#973),
and 143-count-keeps-elidable-named-map-native does the same
for a map whose argument is an object,
which has no lambda for 142- to stamp (#635),
as does 143-count-keeps-elidable-ref-map-native
for one whose operator arrives in a register (#1014).
Nothing is forfeited by doing so β
in exactly that case the fused mapMulti
would have optimised a pipeline that was never going to run.
The dividing line is whether a stage clears SIZED, which is not the same as whether it changes the element count. Measured against the JDK rather than reasoned about:
limit,skip,sorted,peek,map,boxedandunorderedkeep the elision, so the guard fires and the chain stays native;filter,distinct,takeWhile,dropWhile,flatMapandmapMulticlear SIZED, so the fusion is kept.
limit(3) drops elements and still elides,
because SliceOps computes the new size from the old one.
Where a SIZED-clearing stage is present the JVM walks the stream anyway,
so the fusion costs nothing and is kept.
The sections above explain what the plugin refuses to fuse
because fusing it would be wrong.
This one it would like to fuse and cannot yet.
It is a fusion barrier:
the operations on either side of it still fuse into a mapMulti,
the unrecognised call stays native between them,
and the code is correct, just not collapsed into a single pass.
// A skip(n) whose count is not a compile-time constant (#969). 220 and
// 212 bake the count into the countdown by capturing the opcode that
// pushes it, so they admit only lconst_0, lconst_1 and ldc; anything
// computed is left as it is rather than baked in wrong.
stream.skip(list.size() - 3L)
// A map or a filter reading its operator from an INSTANCE field
// (#1014). 215-recognize-ref-map takes an aload or a getstatic, each of
// which pushes the operator on its own; a getfield's receiver was
// pushed by an opcode ahead of the run, and relocating the getfield
// into the state append 502 moves would strand it.
stream.map(this.mapper)A CAPTURING mapMulti stage is also left as two calls,
on every stream kind:
111 declines a capturing invokedynamic,
so neither 461 nor 462 ever sees a pragma to fuse.
A method reference is fused only when the wrapper the rules synthesise
for it takes the element and returns the result with no adaptation
beyond a single unboxing:
103 covers the unbound-instance reference,
104 the static one,
105 the return-dropping one a peek needs,
107 the one whose erased return needs narrowing,
and 108 the one whose return needs unboxing.
The referenced method may return void β
peek(X::traced) over a static void traced(Long)
is 104's shape, since a Consumer.accept returns void
and the instantiated SAM type then equals the target signature (#1053).
static Integer length(String s) { ... }
stream.mapToInt(X::length) // fused; 108 spells out intValue()
stream.mapToLong(X::length) // native; needs intValue() then i2l
stream.reduce(Integer::sum) // native; two arguments
stream.map(StringBuilder::new) // native; constructor referenceYou can use this plugin with Gradle too, but it requires
some additional steps. You need to add the following to your build.gradle file:
task hone(type: Exec, dependsOn: compileJava) {
commandLine 'mvn',
"-Dhone.target=${buildDir}",
"-Dhone.classes=${buildDir.toPath().relativize(sourceSets.main.output.classesDirs.singleFile.toPath())}",
'-Dhone.rules=streams/*',
'org.eolang:hone-maven-plugin:0.29.4:build',
'org.eolang:hone-maven-plugin:0.29.4:optimize'
}
compileJava.finalizedBy hone
classes.dependsOn honeSee how it works in this example.
If you ran hone-maven-plugin in a Docker container, you might face issues if
your Docker image doesn't have phino
installed. In this case, hone-maven-plugin
will try to run a Docker container inside your Docker container (DinD).
This might lead to problems with
volume mounting.
While it's technically possible to implement this (by specifying correct volumes on your host machine), we highly recommend avoiding such a setup.
Here is the result of the latest processing of a large Java class from JNA:
Input: com/sun/jna/Pointer.class
Size of .class: 22Kb (22Kb bytes)
Size of .xmir after disassemble: 2Mb (2Mb bytes, 52996 lines)
Size of .phi: 617Kb (617Kb bytes, 14843 lines)
Size of .xmir after unphi: 2Mb (2Mb bytes, 52981 lines)
Optimization time: 10s (9727 ms)
The results were calculated in this GHA job on 2026-06-09 at 08:04, on Linux with 4 CPUs.
Here is the result of running the plugin against a number of mid-size,
mature open-source Java projects from GitHub. Each project is pinned to
a specific commit (rather than its moving master head) so that the
table stays reproducible across runs. For each pinned commit, the
workflow shallow-fetches that revision, runs mvn clean test to record
a baseline, applies hone-maven-plugin to every target/classes/
directory, and then re-runs the tests to check that the bytecode still
passes the project's own test suite. The Edits column reads as
NN/MM: NN is the number of classes whose count of references to
Stream, IntStream, LongStream or DoubleStream went down, and MM
is the number of compiled .class files that reference any of them β that
is, how many of the stream-using classes lost a stream call. A class the
plugin rewrote without removing one of those references is not counted,
so 0/27 means "no stream call disappeared", not "nothing was touched".
| Repository | Forks | LoC | Classes | Before | Edits | Hone | After |
|---|---|---|---|---|---|---|---|
| commons-io | 737 | 64255 | 429 | 307s | skipped | skipped | 308s |
| commons-configuration | 156 | 52534 | 400 | 32s | skipped | skipped | 32s |
| vavr | 679 | 68282 | 388 | 77s | skipped | skipped | 78s |
| commons-compress | 320 | 79083 | 619 | 91s | skipped | skipped | 92s |
| mybatis-3 | 12853 | ? | ? | ? |
?/? | ? | ? |
| json-schema-validator | 353 | ? | ? | ? |
?/? | ? | ? |
| commons-collections | 529 | 73500 | 614 | 25s | skipped | skipped | 26s |
| mybatis-dynamic-sql | 217 | ? | ? | ? |
?/? | ? | ? |
| commons-csv | 306 | 11110 | 20 | 11s | skipped | skipped | 10s |
| commons-lang | 2028 | 104400 | 433 | 870s | skipped | skipped | 694s |
The results were calculated in this GHA job on 2026-09-25 at 08:17, on Linux with 4 CPUs.
skipped means every module of the project has at least one class
compiled below Java 16, so the streams rules never ran.
Fork repository, make changes, then send us a pull request.
We will review your changes and apply them to the master branch shortly,
provided they don't violate our quality standards. To avoid frustration,
before sending us your pull request please run full Maven build:
mvn clean install -PquliceYou will need Maven 3.3+, Java 11+, and Docker installed.
The versions of EO and
JEO,
that we use, are defined in the pom.xml file.