Skip to content

Explore reducing ProcessedOption allocation cost in createDirect #581

Description

@stalep

Context

After implementing seven startup optimizations (#573, #574, #575, #576, #577, #579), re-profiling with async-profiler shows that ProcessedOption.createDirect() is now the #1 aesh method in both CPU and allocation profiles:

  • Container CPU: 18.5% (248 samples out of 1340)
  • Container allocations: 69.8% (4124 samples out of 5908) — GeneratedProcessedOption objects
  • Full startup CPU: 2.1% (96 samples out of 4556)
  • Full startup allocations: 47.0% (902 samples out of 1921)

This is expected — after eliminating avoidable overhead (lazy map builds, NO_COLOR lookups, clearOptions on clean commands, per-value converter allocation), the irreducible cost of constructing ProcessedOption objects dominates.

What createDirect does

public static ProcessedOption createDirect(...) {
    ProcessedOption opt = new GeneratedProcessedOption();  // 1 allocation (~280 bytes, ~35 fields)
    opt.shortName = shortName;      // 7 direct field assignments
    opt.name = name;
    opt.description = description;
    opt.type = type;
    opt.fieldName = fieldName;
    opt.optionType = optionType;
    opt.converter = converter;
    opt.fieldAccessor = fieldAccessor;
    return opt;
}

The no-arg constructor adds 5 more field assignments (selectorType, valueSeparator, properties, values, defaultValues). Then the generated code calls setter methods for non-default attributes (setRequired, setNegatable, setInherited, etc.).

For the jbang-like CLI with 12 commands and ~100+ total options, this means ~100 GeneratedProcessedOption allocations per startup.

Potential Optimization Approaches

1. Split ProcessedOption into template + mutable state (Medium effort, medium impact)

Separate the immutable metadata (name, description, type, converter, fieldAccessor, required, negatable, negationPrefix, inherited, etc.) from the mutable parse state (values, properties, cursorOption, negatedByUser, longNameUsed, endsWithSeparator).

The template could be created once and shared across invocations for the same command class. Only the mutable state wrapper (~10 fields instead of ~35) would be allocated per invocation. This would roughly halve the per-option allocation.

Tradeoff: Significant refactoring — every reader of ProcessedOption fields would need to go through the template for metadata vs the state for parse values. The clear() method would only reset the state wrapper.

2. Reduce field count via bit packing (Low effort, low impact)

Several fields have small value domains and are rarely overridden from defaults:

Field Type Default Could be
selectorType enum (5 values) NO_OP 3 bits in flags
visibility enum (3 values) BRIEF 2 bits in flags
completeFallback enum (4 values) DEFAULT 2 bits in flags
required boolean false 1 bit
negatable boolean false 1 bit
inherited boolean false 1 bit
optionalValue boolean false 1 bit
optionalWrapped boolean false 1 bit
askIfNotSet boolean false 1 bit
acceptNameWithoutDashes boolean false 1 bit
overrideRequired boolean false 1 bit
isUrl boolean false 1 bit
ansiMode boolean true 1 bit

All 13 fields fit in a single int (16 bits used). This eliminates 12 fields × 1 byte each = 12 bytes, but with object alignment it may save 0-16 bytes depending on JVM layout. CPU impact is minimal — the getter would do a bit mask instead of a field read.

3. Null-default pattern for rarely-used String/Object fields (Low effort, low impact)

Fields like negationPrefix (default "no-"), helpGroup (default ""), descriptionUrl (default null) could use null as the default and have getters return the default value:

// Instead of: private String negationPrefix = "no-";
private String negationPrefix; // null means "no-"
public String getNegationPrefix() { return negationPrefix != null ? negationPrefix : "no-"; }

Saves the reference assignment per option for rarely-overridden fields.

4. Pre-build at compile time (High effort, high impact)

The annotation processor could generate the entire ProcessedCommand with pre-built ProcessedOption arrays as static constants. Each invocation would deep-clone the template rather than rebuilding from scratch. This is the most impactful but also the most architecturally invasive approach — deferred for Quarkus compatibility concerns.

Current Performance Baseline

After all optimizations in this session:

Metric Value
Full startup per command (jbang-like, 12 subcommands) 8.7 us/cmd
Previous baseline (before optimizations) 24.0 us/cmd
Improvement 2.8x faster
Application CPU (excluding JIT+GC) ~20% of profile
JIT compilation ~45% of profile
GC/native overhead ~35% of profile

The remaining aesh application work is well-distributed across createDirect (option construction), buildLookupMaps (map construction), populateObject (value injection), and resetField (field reset). No single method dominates enough for a targeted micro-optimization to yield significant gains.

Recommendation

Approach #1 (template + state split) offers the best balance of impact and feasibility. It could reduce per-option allocation by ~50% and improve cache locality since parse-hot fields would be co-located. However, it is a significant refactoring effort and should be done as a dedicated feature branch with thorough testing.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions