Toward Condensers

原文はこちら。なお、2023/08/16現在の情報に基づきます。
The original article was written by Brian Goetz, Mark Reinhold, and Paul Sandoz.
https://openjdk.org/projects/leyden/notes/03-toward-condensers

シンプルかつ抽象的でImmutableな、データ駆動型のアプリケーション・モデルを導入するために、composable condenserの概念を詳しく説明します。このcomposable condenserはモデル・インスタンスのトランスフォーマーとして表現できます。

Condensing Code
https://openjdk.org/projects/leyden/notes/02-shift-and-constrain#condensing-code
https://logico-jp.io/2022/10/22/selectively-shifting-and-constraining-computation/#condensing-code

このモデルは単純なcondenserを表現するのに十分です。この文書では、2つの例を含めます。

Leydenの主要なテーマは、計算の選択的なシフトと制約です。

Selectively Shifting and Constraining Computation
https://openjdk.org/projects/leyden/notes/02-shift-and-constrain
https://logico-jp.io/2022/10/22/selectively-shifting-and-constraining-computation

Shifting computation(計算のシフト)とは、通常実行時に実行される作業を、それよりも前の段階、つまり実行時の前、または実行時の後のある時点に移動するということです。static transformation(静的変換、例えば、Class.forName("com/foo/Bar")ldc Constant_Class_info[com/foo/Bar] で置き換える)やdynamic analysis(動的解析、例えば、アプリケーションコードをビルド時に実行し、その結果を後で使用する)によって計算をシフトできます。Constraining computation(計算の制約)とは、通常アプリケーションが持つ実行時オプションの範囲を狭めることを意味します。選択的なシフトと制約とは、どの種類の計算をシフトするか(例えば、コードをAOTコンパイルするが、動的最適化を保持する)、いつそれらをシフトするか(例えば、通常のコンパイルおよびテストのサイクルではAOTコンパイルする必要はないが、プロモーションビルドではAOTコンパイルを行う)を開発者に対して柔軟に選択できるようにすることを意味します。

計算シフトの主な手段はcondenserです。condenserはプログラムを変換するコンポーネントで、指定された制約(例えば、「クラスXは再定義しない」など)の下で意味的に等価なプログラムを生成しますが、より小さく、より速く、特定の実行環境に適したプログラムを生成する可能性があります。可能な変換の例としては以下のようなものがあります。

  • リフレクティブ・コールを ldc命令に置き換える
  • 動的プロキシとlambdaプロキシを事前に生成する
  • 定数を伝搬して畳み込む
  • デッド・コードを取り除く
  • AOTコンパイルしてネイティブ・コードにする、など

Research and develop new condensers and new language features
https://openjdk.org/projects/leyden/notes/02-shift-and-constrain#research-and-develop-new-condensers-and-new-language-features
https://logico-jp.io/2022/10/22/selectively-shifting-and-constraining-computation/#research-and-develop-new-condensers-and-new-language-features

その他にも、Javaプラットフォーム仕様の変更を必要としないものもあれば、仕様の追加サポートを必要とするものもあります。

各condenserは、おそらく狭い範囲に焦点を絞ったタスクを実行するでしょう。開発者は、アプリケーションで実行するcondenserを選択し、異なるビルドターゲットに対して異なるcondenserを異なる構成で実行できます。複数のcondenserを実行すると、あるcondenserの出力が次のcondenserの入力になります。このように、condensation(凝縮)はストリームパイプラインに似ています。

  • ソース(アプリケーションの初期構成)
  • 中間ステージ(アプリケーションを変換するcondenser)
  • 終端ステージ(実行時に使用するのに適した成果物を生成する)

中間ステージの考慮事項は、終端ステージのそれとは異なるため、JVMによる最終的な消費(=利用)のためにアプリケーションをパッケージ化する目的とは別のAPIをcondenser用に定義します。このドキュメントでは、主にcondenserが使うAPIと、condenserが利用・生成するデータの形式に焦点を当てます。

condenserが最終的に特別な種類のjlinkプラグインなのか、あるいは凝縮にjlinkとは別のツールを必要とするのかは、後に解決されることでしょう。今のところ、アプリケーション構成上で1つ以上のcondenserを実行する、ある種の “condenser runner”をプロトタイプ化します。

The condenser pipeline

condensation process(凝縮プロセス)は、アプリケー ションの構成の記述から始まります。これには、モジュールファイル、JARファイル、クラスパス情報、構成メタデータ (--add-module で追加する対象など)、アプリケーションのメインクラスの選択などが含まれます。最初のcondenserは、ベースとなるアプリケーション構成を読み取り、要素を追加、修正、削除して新しい構成を生成します。続いて、次のcondenserがこの変更された構成を操作し、パイプラインの終端ステージに到達するまでこれを繰り返します。構成には、(依存関係)解決済みのクラス・メタデータ、ヒープ・オブジェクト、AOTコンパイルされたコード、そして “クラスXは再定義できない “といった制約が最終的に含まれると期待されます。

condenserにファイルシステムからクラスファイルやその他のアプリケーション構成を読み込むように要求するのではなく、condenserがアプリケーション構成のモデル上で動作するように制約を課します。condenserパイプラインの初期フェー ズではアプリケーション構成のモデルを抽出し、パイプラインを通過する際にcondenserが交互にモデルを操作します。パイプラインの最終フェーズでは、モデルをデプロイメント成果物に抽出します。この成果物は、Javaランタイムモジュールの場合もあれば、より特殊なデプロイメントフォーマットの場合もあります。

condenser pipeline

condenserの主な動作は、アプリケーション構成の拡張や変更です。一般的に、これはアプリケーション・クラスファイルを生成もしくは修正する形を取りますが、他のリソースやメタデータの生成や修正も含まれます。このような変換は、アプリケーションの静的解析だけでなく、動的解析、つまりトレーニングの実行やアプリケーションの動作の観察によっても情報を得られます。

condenserを記述するための API は、見かけによらずシンプルです。

interface Condenser {
    ApplicationModel condense(ApplicationModel model);
}

これは、condenserがアプリケーションモデル上の「単なる」機能変換であり、したがってcondenserは簡単に構成できるという事実を強調しています。

The application model

アプリケーション・モデルは、凝縮されるアプリケーションに関連するすべてを表現できる必要があります。これには以下のものが含まれます。

  • クラスファイル
  • モジュール
  • シンボル
  • クラスパスやメインクラスやモジュールなどのランタイムメタデータ
  • --add-opens のようなセマンティックなコマンドラインフラグなどのデータやメタデータ、など

が含まれます。

ApplicationModel API と凝縮プロセスには以下の目標を採用しています。

Abstracted away from the representation
(表現から抽象化)
コンデンサーは、作業を行う際にファイルを直接読み書きすべきではありません。モデルの変更という観点から動作を表現し、ツールに表現を処理させるべきです。
Explicitly specified
(明示的に指定)
アプリケーションモデルとモデル要素のセマンティクスは、プラットフォーム仕様によって厳密に定義されます。モデルのスキーマは、新しい形式のメタデータと制約を表すために時とともに進化しますが、コンデンサーは、モデルのスキーマ内で動作し、プラットフォーム仕様によって定義されたそのセマンティクスを尊重するように制約されます。アプリケーションモデルは、プラットフォームが指定していない要素を含むべきではなく、APIはこれを強制するためにシーリングを使用する必要があります。
Immutable
(不変)
アプリケーションモデルは、アプリケーション構成の不変な表現です。アプリケーションモデルの機能変換として表現したものがcondensation(凝縮)です。
Scrutable
(判読可能)
「そのコンデンサーは何をしたのか?」という質問に回答できる必要があります。

Data model

アプリケーション・モデル API のデータを運ぶ部分は、データ・モデルから機械的に導出されます。データ・モデルは、エンティティ・キーとエンティティ・プロパティで構成されますが、キーは、モデル内のエンティティ(クラス、モジュール、jar など)を識別します。モデリング上の選択として、私たちは、エンティティ・キーには、エンティティの識別特性(つまり主キー)以外の実際のデータは含まれないという規律を採用しています。なお、これらは、単に名前空間内のエンティティに名前を付けるだけです。エンティティに関するすべてのデータ(クラスのバイトやJARのエントリなど)は、エンティティのプロパティに格納されます。このプロパティは当該エンティティの特定のファクトを表します。プロパティにはカーディナリティがあり、同じエンティティに対して存在可能なファクトの個数を制限します(クラスには1つの定義があり、JARには多くのエントリがあります)。とんでもなく単純なモデルから始めまましょう。例えば、

  • (モジュールパスと環境に組み込まれたシステムモジュールから解決される)クラスパスとモジュールのリストがある
  • クラスパスとモジュールはコンテナで構成される
  • コンテナには種類(JAR、モジュールJAR、JMOD)がある
  • コンテナにはクラスとリソースが含まれる
  • クラスとリソースはコンテンツを持つ

このモデルを図示できます。ここで、楕円はエンティティ、長方形はプレーンなデータ、矢印はarity(個数)制約が付属したプロパティを表します。

digraph of condenser model

このモデルを、多かれ少なかれ機械的に、モデルをクエリーするための API に変換できます。

sealed interface EntityKey { }
record ModulesKey() implements EntityKey { }
record ClassPathKey() implements EntityKey { }
record ContainerKey(String name) implements EntityKey { }
record ClassKey(ContainerKey container, ClassDesc desc) implements EntityKey { }
record ResourceKey(ContainerKey container, String name) implements EntityKey { }
record ModuleResourceKey(ContainerKey container, String name,
                         ModuleResourceKind kind) implements EntityKey { }
enum ModuleResourceKind {
    CONF, INCLUDE, LEGAL, MAN, LIB, BIN
}
enum ContainerKind {
    JAR, MODULAR_JAR, JMOD
}
interface Model {
  Stream<ContainerKey> modules();
  Stream<ContainerKey> classPath();
  Stream<ClassKey> containerClasses(ContainerKey containerKey);
  Stream<ResourceKey> containerResources(ContainerKey containerKey);
  Stream<ModuleResourceKey> containerModuleResources(ContainerKey containerKey);
  ContainerKind containerKind(ContainerKey containerKey);
  ModuleResourceContents moduleResourceContents(ModuleResourceKey resourceKey);
  ResourceContents resourceContents(ResourceKey resourceKey);
  ClassContents classContents(ClassKey classKey);
}

モデルのクエリは、ストリームに大きく依存します。アプリケーションのすべてのパブリック・インターフェースを見つけたい場合は以下のようにすることで実現できます。

List<ClassDesc> publicIntfs =
    Stream.concat(model.modules(), model.classPath())
    .flatMap(model::containerClasses)
    .map(model::classContents)
    .map(ClassContents::classModel)
    .filter(c -> c.flags().has(ACC_PUBLIC) && c.flags().has(ACC_INTERFACE))
    .map(c -> c.thisClass().asSymbol())
    .toList();

これでやっていることは以下の通りです。

  1. モジュールとクラスパスの要素(=コンテナのストリーム)を連結
  2. これらのコンテナからクラスのエントリを抽出
  3. それぞれをClassModel(新しいjava.lang.classfile APIで定義される)にマッピング
  4. クラスがパブリック・インターフェースを表しているかどうかをフィルタリング
  5. 最後にそれぞれのシンボリック記述子を取得

JEP draft: Class-File API (Preview)
https://openjdk.org/jeps/8280389

Transforming the model

モデルの要素やモデル自体はimmutableの値です(これは最初は疑問に思われるかもしれませんが、ストリームを使ってモデルをクエリするためです。ストリームは遅延型なので、クエリの最中にモデルを変更したくありません)。モデルの更新は、モデルにアップデートを適用し、新しいモデルを生成することで行われます。アップデートは事実上、適用される変更(追加、変更、削除)の集合です。モデルにupdater(=builder)を要求し、変更を蓄積して、その後アップデートをモデルに適用して新しいモデルを生成します。

interface Model {
    ...
    ModelUpdater updater();
    Model apply(ModelUpdate updater);
}

モデル・アップデータには、エンティティ・プロパティ・マッピングの追加・置換・削除のための数多くの変更可能なメソッドがあります。これはモデルに対するトランザクションのビルダーです。繰り返しますが、updater APIはデータモデルからほぼ機械的に導出されます。

public interface ModelUpdater {
    // modules -> container
    ModelUpdater addToModules(ContainerKey containerKey, ContainerKind kind);
    ModelUpdater removeFromModules(ContainerKey containerKey);
    // class path -> container
    ModelUpdater addToClassPath(ContainerKey containerKey);
    ModelUpdater removeFromClassPath(ContainerKey containerKey);
    // container -> module resource
    ModelUpdater addToContainer(ModuleResourceKey resourceKey,
                                ModuleResourceContents resourceContents);
    ModelUpdater removeFromContainer(ModuleResourceKey resourceKey);
    // container -> resource
    ModelUpdater addToContainer(ResourceKey resourceKey,
                                ResourceContents resourceContents);
    ModelUpdater removeFromContainer(ResourceKey resourceKey);
    // container -> class
    ModelUpdater addToContainer(ClassKey classKey,
                                ClassContents classContents);
    ModelUpdater removeFromContainer(ClassKey classKey);
}

さらに、モデルには以下のような整合性制約がある可能性があります。例えば、

  • クラスのキーのコンテナは、追加先のコンテナと一致しなければならない
  • コンテナは、モジュールとクラスパスの両方の要素として存在できない

といったものです。これらは、モデル・アップデータによって強制できます。関係論理と同様に、重複する同一のエンティティ・プロパティ・マッピングは同じファクトとみなされます。ファクトは独立したアイデンティティを持ちません。多値プロパティでは、追加順序が尊重され、LinkedHashSetに似たセマンティクスが与えられます。

このモデルは明らかに限定的ですが、仕様の変更を必要としないcondenserの多くを表現するにはすでに十分なものです。このモデルとモデルAPIは、追加エンティティ(コマンドラインフラグなど)、プロパティ(プロファイリング情報など)、制約(「クラスXは再定義できない」など)をサポートするように、時間をかけて拡張される予定です。

Modes of operation

condenserは様々なモードで動作できます。最も単純なのは、純粋なクラスファイルの変換です。

  1. クラスファイルを列挙
  2. 静的解析(ローカルまたはグローバル)を実行
  3. 解析に基づいてクラスファイルの一部を変換(または新しいファイルを生成)

このモードで動作できるcondenserには、Lambdaプロキシをクラスに展開したり、静的に識別できるインターフェースを持つ動的プロキシの呼び出しを展開したりするものがあります。

凝縮することでコードを実際に実行することもできます。Model APIは、モデルを一時的にJDKイメージに展開し、指定されたメインエントリーポイントとコマンドラインフラグでそのイメージを使用してJVMを起動する手段を提供します。これによってcondenserは、トレーニングを実行して、プロファイリングデータを収集したり、クラスの変換やメタデータの生成に役立つプログラム構成を抽出したりできます。このモードで動作するcondenserの例としては、トレーニング実行中にインスタンス化される動的プロキシの呼び出しの拡張や、lambda formの事前生成などがあります。

Example: Lambda forms

JDKのビルド・プロセスには、メソッド・ハンドルに関連する内部クラスであるlambda formを事前に生成する特注の凝縮プロセスがすでに含まれています。このプロセスは、メソッド・ハンドルのインフラストラクチャの計算を実行時からビルド時に選択的にシフトさせることで、起動時間を短縮します。

JDKビルドは現在、予備的なJDKをビルドし、その後、メソッド・ハンドル・インフラストラクチャを実行して、生成されたlambda formクラスのリストを出力する最小限のトレーニング・プログラムを実行します。このリストをjlinkプラグインに入力すると、クラスが生成され、JDKの最終ビルドのjava.baseモジュールに追加されます。

この処理は、コンデンサーと前述の API を使用して実装できます。以下は、この処理をコンデンサーとして実行するコードでの例です。

// Create the path to the not-yet-condensed JDK build
Path javaHome = Path.of(...);
// Set up the class path for the main class that exercises MH infrastructure
var classPath = List.of(Path.of("make/jdk/src/classes"));
// Set up the module path to the base module of the JDK build
var modulePath = List.of(javaHome.resolve("jmods/java.base.jmod"));
// Initiate the application model from the class and module paths
var model = Condensers.init(classPath, modulePath);
// Create the lambda form condenser with the main class to run
var lfc = new LFCondenser(javaHome,
                          "build.tools.classlist.HelloClasslist");
// Condense to obtain a new application model
model = lfc.condense(model);

凝縮後、更新されたアプリケーション・モデルからファイル・システム上のjava.baseモジュールを更新できます。LFCondenserの実装も非常にシンプルです。

@Override
class LFCondenser {
    private final Path javaHome;
    private final String mainClass;
    public LFCondenser(Path javaHome, String mainClass) {
        this.javaHome = javaHome;
        this.mainClass = mainClass;
    }
    public ApplicationModel condense(ApplicationModel m) {
        // Distill the current model to an executable application
        var app = m.distill(location, javaHome);
        // Run with a system property enabling profile output on standard out
        Process p = app.run(List.of("-Djava.lang.invoke.MethodHandle.TRACE_RESOLVE=true",
                                    mainClass));
        // Collect the profile as a string
        String trace;
        try (var in = p.getInputStream()) {
            trace = new String(in.readAllBytes());
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
        // Get the java.base module
        ContainerKey java_base = m.modules()
            .filter(cK -> m.containerKind(cK) == ContainerKind.JMOD)
            .filter(cK -> cK.name().equals("java.base"))
            .findFirst().orElseThrow();
        // Generate classes from the profile
        Map<String, byte[]> g = SharedSecrets.getJavaLangInvokeAccess()
            .generateHolderClasses(new BufferedReader(new StringReader(trace)).lines());
        // Update the classes in java.base
        ModelUpdater mu = m.updater();
        g.forEach((s, bytes) -> {
            ClassKey classK = new ClassKey(java_base,ClassDesc.ofInternalName(s));
            mu.addToContainer(classK, ClassContents.of(bytes));
        });
        // Apply the updates and return the new model
        return mu.apply();
    }
}

Example: Replacing Class::forName with LDC

シンプルなcondenserは、アプリケーション・モデルに存在する全てのクラスの内容をスキャンし、文字列引数が定数値であるClass::forNameの呼び出しをLDC命令で置き換えることができます。

@Override
public ApplicationModel condense(ApplicationModel model) {
    ModelUpdater updater = model.updater();
    // Stream through all classes
    Stream.concat(model.modules(), model.classPath())
        .flatMap(model::classes)
        .forEach(classKey -> {
            // Obtain the ClassModel for the class and transform it
            // if there are matching Class::forName invocations
            ClassModel cm = matchAndTransform(model.classContents(classKey).classModel());
            if (cm != null) {
                // If transformed update the class contents
                updater.addToContainer(classKey, ClassContents.of(cm));
            }
        });
    return updater.apply();
}
ClassModel matchAndTransform(ClassModel classModel) {
    var modifiedClassModel = new AtomicBoolean();
    // Transform the class model
    byte[] newBytes = cm.transform(ClassTransform.transformingMethods((methodBuilder, me) -> {
        if (me instanceof CodeModel codeModel) {
            boolean m = matchAndTransform(methodBuilder, codeModel);
            modifiedClassModel.compareAndSet(false, m);
        } else {
            methodBuilder.accept(me);
        }
    }));
    return modifiedClassModel.get() ? Classfile.parse(newBytes) : null;
}
boolean matchAndTransform(MethodBuilder methodBuilder, CodeModel codeModel) {
  // Traverse the code model replacing Class::forName invocation instructions
  // with LDC instructions.  Return false if there were no modifications.
  ...
}

この複雑さはすべて、CodeModelを操作するメソッドmatchAndTransformにカプセル化されています。このメソッドは、java.lang.classfiletransformメソッドを使用します。モデル内のそれぞれのinvokestatic命令に対して、このメソッドは命令がClass::forNameメソッドを呼び出しているかどうかをチェックし、さらにスタック上のオペランドがStringの定数値であるかどうかをチェックします。もしそうであれば、このメソッドはinvoke命令を、バイナリ名がこのString定数値であるクラス記述を参照する ldc命令に置き換えます。また、オペランドの生成に関連する命令が他の命令に影響を与えない場合は、その命令を削除します。

一般的に、この種のcondenser(定数値で動作するcondenser)は、アプリケーション全体で定数畳み込みを実行する別のコンデンサーが生成するモデルを使うことにより恩恵を受けます。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください