この記事は中国語でもご覧いただけます 简体中文。
「PyTorch におけるプロファイリング」シリーズの第1部では、torch.add(torch.matmul(x, w), b) を用いて PyTorch プロファイラのトレースの読み方を学びました。またその過程で、CPU ディスパッチチェーン、起動オーバーヘッド、オーバーヘッドバウンドとコンピュートバウンドの違い、torch.compile の内部など、いくつかのトピックについても議論しました。
第2回目となる本記事では、一段階上の階層に進みます。手書きの matmul-add のペアを nn.Linear(bias=True)に置き換えます。これは、すべての深層学習モデルが使用する基本的な構成要素です。次に、これを3つ積み重ね(今回の例に特有)、間に活性化関数を挟んで多層パーセプトロン(MLP)ブロックを形成します。
このブログ記事で使用するスクリプトはこちらです:
02_linear.py、03_simple_mlp.py、および03_kernels_mlp.py。前回と同様、別のタブで開いて、読みながらコードを追うことをおすすめします。スクリプトの実行にはNVIDIA A100-SXM4-80GBGPU を使用しています。Hugging Face のインフラストラクチャ上で GPU を簡単にセットアップし、Spaces の Dev Mode を使用してスクリプトを実験することも可能です。また Hugging Face Jobs パイプライン を使用してスクリプトを実行することもできます。
始める前に、私たちが繰り返し活用する2つのアイデアを簡単に振り返っておきましょう:
- GPU カーネルとは、GPU の多数のスレッド上で並列に実行されるプログラムです。
- CPU はこれらのカーネルをスケジュールして起動します。プロファイラトレースで目にする PyTorch のオーバーヘッドの大部分は、このスケジューリング作業です。
matmul-add から Linear へ
nn.Linear は、第1部で既にプロファイリングした同じ行列乗算と加算をモジュールとしてラップしたものです。唯一の違いは、重みとバイアスをパラメータとして保持し、PyTorch ユーザーが慣れ親しんだ forward メソッドを公開している点です。
# bias=True would truly emulate the multiplication and addition
# operations we have seen in part 1 of the series
linear_layer = nn.Linear(in_dim, out_dim, bias=True)
y = linear_layer(x)
この演算は次のように表せます:
y = x @ w.T + b
ここで x は入力、w は重み、b はバイアスです。02_linear.py を実行してプロファイルを確認してみましょう。
uv run 02_linear.py --batch 1024 --in_dim 32 --out_dim 64
uvx trace-util traces -b traces
trace-utilは、トレースを Hugging Face バケット に同期し、ターミナル上に Preffeto の URL を提供するユーティリティです。
![]() |
|---|
Figure 1: nn.Linear のプロファイラトレース |
図1は、線形層のフォワードコールのプロファイラトレースを示しています。線形層の forward コールを、前のトレースと同様の schedule 設定(wait=1、warmup=1、active=3)でトレースしています。そのため、CPU レーンと GPU レーンに3つの Profile Step が表示されています。
転置は何をしているのか?
![]() |
|---|
| Figure 2: 転置の CPU 行 |
図2のようにプロファイラトレースを拡大してみると、aten::addmm(乗算と加算)オペレーションの前に aten::t(転置)オペレーションがあることがわかります。nn.Linear は重みパラメータを転置してから入力と乗算していることがすでに推測できます。これが aten::t オペレーションが見られる理由です。
重要な点として、aten::t は実際にはデータをコピーしたり再配置したりするわけではなく、テンソルのメタデータ(形状とストライド)を CPU 上で書き換えて転置行列を表現するだけです。GPU 上でカーネルを起動することはありません。これは、トレースの GPU レーンを見るか、プロファイラテーブルの aten::t 行と CUDA 上での所要時間を確認することで検証できます。
なぜ mul と add の別々のカーネルがないのか?
![]() |
|---|
Figure 3: 線形層のプロファイルに aten::add がない |
図3に示すように、線形層のディスパッチチェーンには aten::add(バイアス加算)が存在しません。これは、バイアス加算が エピローグ と呼ばれる仕組みを使って、行列乗算カーネルに 折り込まれ ているためです。
エピローグとは、GEMM(GEneral Matrix Multiply)カーネルが結果を HBM(High Bandwidth Memory、GPU のメインメモリ)に書き戻す直前に実行する小さな計算のことです。バイアスの加算、活性化関数の適用、定数によるスケーリングなどはすべて典型的なエピローグです。エピローグの目的は、HBM への2回目の読み込みや書き込みを避けることです。メモリトラフィックは演算を高コストにするためです。
nn.Linear は torch.nn.functional.linear を呼び出し、さらに aten::linear を呼び出します。aten::linear は入力を調べ、バイアスが渡されたことを検知すると、matmul と add を別々に実行する代わりに aten::addmm(bias, x, weight) をディスパッチします。addmm は次の計算を行います:
out = x @ weight.T + bias
GPU 上で実行される cuBLAS GEMM カーネルには、バイアス加算のバリアントが組み込まれており、それが aten::addmm が選択するカーネルです。加算が別々のカーネルとして現れないのは、それが matmul カーネルの書き戻し の一部 であるためであり、これがまさにエピローグの役割です。
ここで微妙な点に気づくべきです。第1部の --compile の下で見たカーネル(addmm)は、eager モードの nn.Linear がすでに使用しているカーネルです。ここでは torch.compile が融合するべきものが残っていないことになります。次にこれを確認していきます。
--compile は単一の Linear に役立つか?
フォワードコールをコンパイルしてプロファイラトレースを見てみましょう。(プロファイラトレースは 次のセクション で可視化します)
uv run 02_linear.py --batch 1024 --in_dim 32 --out_dim 64 --compile
uvx trace-util traces -b traces
単一の nn.Linear の forward について、eager とコンパイル済みのトレースを比較すると、次のことがわかります:
- GPU 上で同じ cuBLAS GEMM カーネル。
- CPU 上で同じ
aten::addmmオペレーション。 - コンパイルに特有の CPU レーン上の追加の行がいくつか。
これは心に留めておく価値があります。モデルが遅いと感じたときに torch.compile に頼るのは一般的な反応です。しかし、単一の GEMM-with-bias に対しては、compile ができることはほとんどありません。これはバグではなく、compile が融合を行うためには1つ以上のオペレーションが必要であることを示しています。MLP を見てこれを証明しましょう。
転置はどこへ行ったのか?カーネルレイアウトとプリオペレーション
2つのトレース(eager vs compile)を注意深く見比べると、eager の CPU ディスパッチチェーンにはコンパイル済みのものよりも多くの要素が含まれていることがわかります。
![]() |
|---|
Figure 4: aten::linear が aten::t(転置)と aten::addmm を順に処理する eager ディスパッチチェーン |
![]() |
|---|
Figure 5: 転置オペレーションなしで aten::addmm が直接呼び出されるコンパイル済みディスパッチチェーン |
eager モードの aten::linear 内部の CPU ディスパッチチェーンは aten::t に続いて aten::addmm です(図4)。aten::t が実際に行うことを理解するには、ストライド と ビュー について簡単に detour する必要があります。
テンソルはデータをメモリ上の1つの連続した数値の列として格納します。shape と stride はその列の上に位置するメタデータで、PyTorch にどのように走査するかを指示します: ストライド (s0, s1) は「1行移動するのに s0 要素、1列移動するのに s1 要素をステップする」ことを意味します。メタデータを変更すると、同じ生データに対する異なる ビュー が得られ、コピーは発生しません:
>>> M = torch.tensor([[0, 1],
... [2, 3],
... [4, 5]])
>>> M.shape, M.stride()
(torch.Size([3, 2]), (2, 1)) # two steps per row, one step per column
>>> T = M.t() # transpose
>>> T.shape, T.stride()
(torch.Size([2, 3]), (1, 2)) # shape and stride swapped, data untouched
>>> T
tensor([[0, 2, 4],
[1, 3, 5]])
>>> T.flatten() # forced to materialize, so the data is reordered
tensor([0, 2, 4, 1, 3, 5])
M.t() は1つの数値も移動しませんでした。ストライドが入れ替わった新しいビューを返したため、行ごとに読み取るときに元のバッファ 0, 1, 2, 3, 4, 5 を転置された順序で走査します。基になるデータは同一で、メタデータのみが異なります。
これはまさに線形層内部で aten::t が行うことです: 新しいテンソルを割り当てたりデータをコピーしたりせず、重みの ビュー をストライドを書き換えて生成します。
図5で見るように、compile は GPU カーネルを削除したわけではなく、そのビューをディスパッチする CPU オーバーヘッド を削除しました。Inductor はコンパイル時にビュー連鎖をトレースし、結果のストライドを1度計算し、それらのストライドをハードコードした直接的な aten::addmm 呼び出しを出力しました。GPU が同一の計算を行っている間に、CPU の数マイクロ秒の作業が消えています。
予想されるように、入力データがコンパイラによって事前計算されたストライドに違反すると、エラーがスローされます。
両方のトレースの GPU レーンを見ると、フォワードごとに正確に1つのカーネルがあり、それは 同じ カーネルです:
cutlass_80_wmma_tensorop_bf16_s161616gemm_bf16_32x32_32x1_tn_align8
転置カーネルが実行されなかった場合、誰が GEMM に重み行列を転置された順序で読み取るよう教えたのでしょうか?答えはカーネル名にあります。接尾辞を見てみましょう:
cutlass_80_wmma_tensorop_bf16_s161616gemm_bf16_32x32_32x1_tn_align8
^^
この tn はレイアウト記述子です。cuBLAS と CUTLASS は、入力レイアウトの各組み合わせに対して 別々のカーネルバイナリ を事前コンパイルしています。
n(非転置)と t(転置)は、カーネルが内部ループ中にどのように入力を走査するかを記述します。ディスパッチャの仕事は、入力ストライドを見て、どの接尾辞の組み合わせが一致するかを決定し、正しい事前コンパイル済みカーネルを選択することです。
プロファイラトレース内のカーネル名は、カーネルのアイデンティティのハッシュダンプです。2つの実行で同じカーネル名が表示される場合、GPU は同じ作業を行っています。異なる場合(例:
_tn_vs_nn_、bf16vsfp16、またはs16816gemmvss161616gemm)は、GPU が異なる作業を行っており、ディスパッチャが異なる分岐を取ったことを意味します。この名前を読むことを学ぶことは、トレースを比較する際に最も有用な習慣の1つです。
3つの Linear を積み重ねる: MLP
このセクションでは、多層パーセプトロン(MLP)をプロファイリングします。より興味深いものにするため、GeGLU 活性化関数のバリアントを使用したフィードフォワードネットワークをプロファイリングします(これは実際によく使用されています)。これはまた、深層学習研究の歴史の中で書かれた最も偉大な行の1つに敬意を表する方法でもあります(図6)。
class SimpleGeGLUMLP(nn.Module):
def __init__(self, dim, hidden):
super().__init__()
self.gate_proj = nn.Linear(dim, hidden, bias=False)
self.up_proj = nn.Linear(dim, hidden, bias=False)
self.down_proj = nn.Linear(hidden, dim, bias=False)
def forward(self, x):
g = self.gate_proj(x)
u = self.up_proj(x)
h = F.gelu(g, approximate="tanh")
m = h * u
y = self.down_proj(m)
return y
スクリプト全体はこちらです: 03_simple_mlp.py。次のように実行します:
uv run 03_simple_mlp.py --batch 64 --seq 128 --dim 768 --hidden 3072
uvx trace-util traces -b traces
トレースを開く前に、どのようなものが見られると予想するかを一緒に考えてみましょう。forward 関数はかなりの量の計算を行いますが、そのほとんどはすでに馴染みのあるものです。
3つの aten::linear ディスパッチ(それぞれの nn.Linear 層に対して1つ)と、GeLU 用と乗算用の2つのポイントワイズカーネル起動が予想されます。見る前にこの予想を立てることは、プロファイリングの旅で最も有用な習慣の1つです: トレースを読んで推測を確認または反証するのであり、ゼロから推測を形成するのではありません。
![]() |
|---|
| Figure 7: GeGLU MLP のプロファイラトレース |
![]() |
|---|
| Figure 8: 線形投影トレースでハイライトされたオキュパンシクエリ |
図7から、私たちの直感が正しかったことがわかります。1回のフォワードパス(1つの mlp_fwd)で、GPU は正確に5つのカーネルを実行します。図8は、線形投影層の CPU レーンで見られる「オキュパンシクエリ」をハイライトしています。
| Op | CPU op | GPU kernel | launches |
|---|---|---|---|
gate_proj |
aten::linear |
ampere_bf16_s16816gemm_bf16_128x128_... |
occupancy query + cudaLaunchKernel |
up_proj |
aten::linear |
ampere_bf16_s16816gemm_bf16_128x128_... |
occupancy query + cudaLaunchKernel |
gelu |
aten::gelu |
vectorized_elementwise_kernel<4, GeluCUDAKernelImpl...> |
cudaLaunchKernel |
h * u |
aten::mul |
vectorized_elementwise_kernel<4, ...MulFunctor...> |
cudaLaunchKernel |
down_proj |
aten::linear |
ampere_bf16_s16816gemm_bf16_128x256_... |
occupancy query + cudaLaunchKernel |
3つの GEMM のそれぞれは、起動前に追加の cudaOccupancyMaxActiveBlocksPerMultiprocessor 呼び出しを行います。第1部にこれに関するセクションがあり、こちらでご覧いただけます。これは cuBLAS がグリッドをサイズ調整していることを示しています。ポイントワイズオペレーション(GeLU と mul)は、オキュパンシクエリなしで直接起動します。つまり、「1つの linear」は実際には query + launch であり、「1つのポイントワイズオペレーション」は単に launch です。
![]() |
|---|
| Figure 9: テーブルは一部のオペレーションがカーネルを起動しないことを示している |
aten::t、aten::transpose、aten::reshape、aten::view、aten::as_strided、aten::_unsafe_view の各オペレーションはカーネルを起動しません。これらはテーブル(図9)で 0.000us の CUDA 時間を表示します。これは、CPU 上でテンソルのメタデータ(形状とストライド)のみを書き換えるためです。テーブルをスキャンする読者は、1つの linear あたり約6つのオペレーション名を見ますが、そのうち1つ(mm)のみが GPU に到達します。
なぜ2種類の GEMM カーネルがあるのか?
MLP は matmul のために [batch, seq, dim] を [batch * seq, dim] にフラット化します。コマンドライン呼び出しでは batch に64、seq に128を使用したため、以下に示す 8192(batch * seq = 64 * 128)の値がここから来ています。
トレースから:
| Linear | aten::mm input dims |
M·K·N | cuBLAS kernel | avg CUDA |
|---|---|---|---|---|
gate_proj |
[8192,768] x [768,3072] |
8192·768·3072 |
…128x128…stages_32x5_tn |
0.19ms |
up_proj |
[8192,768] x [768,3072] |
8192·768·3072 |
…128x128…stages_32x5_tn |
0.19ms |
down_proj |
[8192,3072] x [3072,768] |
8192·3072·768 |
…128x256…stages_64x3_tn |
0.17ms |
3つの GEMM はすべて同じ FLOP カウント 2·8192·768·3072 ≈ 38.7 GFLOP を持ちますが、down_proj は約 10% 高速です。同じ作業でも形状が異なる(N=768 ではなく 3072)ため、cuBLAS はその形状に対してより良い再利用を得られる異なるタイル(128×256、より深い stages_64x3 パイプライン)を選択します。
タイル化について深く学びたい場合は、こちらの優れたリソース をご覧ください。
これがまさにテーブルに2つの GEMM 行があった理由です(図9): 128x128 行が gate+up で、128x256 行が down です。
torch.compile は何をするのか?
forward メソッドをコンパイルして可視化する前に、トレースに何が見られると予想するかを再び頭の中で考えてみましょう。これは楽しい実験であり、自分で何かをプロファイリングするたびに繰り返す重要なものです。常に直感に基づいて構築し、何かが一致しない瞬間に立ち止まって理由を解明してください。
uv run 03_simple_mlp.py --batch 64 --seq 128 --dim 768 --hidden 3072 --compile
uvx trace-util traces -b traces
![]() |
|---|
| Figure 10: コンパイル済み GeGLU MLP のプロファイラトレース |
eager モードでは、各 nn.Linear はディスパッチャオペレーションの連鎖(aten::linear → aten::t → aten::transpose → aten::matmul → aten::reshape → aten::mm)に展開されました。これらは実際の GEMM に到達する前に ATen が通過する高レベルラッパーです。torch.compile はこの連鎖を削除します。
コンパイル済みグラフが実行される時点で、linear も matmul も transpose も reshape もなく、それらのメタデータオペレーションは mm の呼び出し方に折り込まれています。3つの裸の aten::mm 外部呼び出しが見られます(図10)。それが同じ GEMM である証明は、カーネル名が eager とバイト単位で同一であることです: gate と up には ...128x128...stages_32x5_tn、down には ...128x256...stages_64x3_tn。
融合された Triton カーネル
![]() |
|---|
| Figure 11: 融合された Triton カーネル |
これが compile のレッスン全体の見出しです。2つの eager ポイントワイズカーネル(GeLU と mul)と reshape が1つのカーネル triton_poi_fused__unsafe_view_gelu_mul_0 に統合されました(図11)。名前を解読してみましょう:
triton: Inductor の Triton バックエンドによって生成された。poi: ポイントワイズ(Inductor はポイントワイズカーネルをpoi、リダクションをred、永続リダクションをperとタグ付けする)。fused__unsafe_view_gelu_mul: 統合されたオペレーション:_unsafe_view(reshape)、GeLU、mul。0: グラフ内のユニーク ID。
なぜこれが利点なのか?eager モードでは、中間結果 h = gelu(g) は完全な [8192, 3072] bf16 テンソル(約50 MB)であり、GeLU カーネルが HBM に書き込み、mul カーネルが即座に読み戻します。融合により、これはレジスタ(チップ内部に存在し、HBM より近いメモリ)に保持されます。Triton カーネルは g と u を1回読み取り、gelu(g) * u を計算し、結果を1回書き込みます。中間結果のグローバルメモリを通じた往復が1回分なくなります。
手動でチューニングされたカーネルを使ってみよう
ここまで、PyTorch(eager)とコンパイラ(torch.compile)にカーネルを選択させてきました。ここで、人間の専門家が手動で作成・チューニングしたカーネルを組み込みます。Hugging Face Hub から kernels ライブラリを使って簡単に取得できる LigerGEGLUMLP レイヤーを使用します。
from kernels import get_kernel
kernels_layers = get_kernel("kernels-community/liger-kernels", version=1).layers
kernels_geglu_mlp = kernels_layers.LigerGEGLUMLP(Config()).to(device, dtype=torch.bfloat16).eval()
完全なスクリプトはこちらです: 03_kernels_mlp.py。
uv run 03_kernels_mlp.py --batch 64 --seq 128 --dim 768 --hidden 3072
uvx trace-util traces -b traces
![]() |
|---|
Figure 12: LigerGEGLUMLP レイヤーのプロファイラトレース |
図12は、Hub の Liger カーネルを使用した LigerGEGLUMLP レイヤーのプロファイルを示しています。
なぜ kernels ライブラリを使うのか
Triton や CUDA でカーネルを書くことは一つの問題であり、それを 出荷 することは別の問題です。カーネルは、GPU アーキテクチャ、CUDA バージョン、PyTorch バージョンの正確な組み合わせに対してコンパイルされる必要があります。これは通常、問題が発生するステップです(「自分のマシンでは動く」、nvcc が見つからない、Triton バージョンが間違っている)。
kernels ライブラリは、そのビルドステップをあなたのマシンから移動します。get_kernel("kernels-community/liger-kernels", version=1) は Hugging Face Hub から 事前ビルド済み、バージョン固定 のカーネルパッケージをダウンロードし、ローカルにキャッシュします(ここでは ~/.cache/...kernels-community--liger-kernels の下)。利点は次のとおりです:
- カーネルは CI で多くのアーキテクチャとバージョン組み合わせに対して一度コンパイルされます。自分でコンパイルする代わりに、正しいバイナリをダウンロードします。
version=1は正確なビルドを固定するため、誰もがあなたのスクリプトを実行するときに同じカーネルを取得します。「パッケージを更新したら遅くなった」ということはありません。- パッケージはドロップイン可能な
nn.Module(LigerGEGLUMLPなど)を持つ.layers属性を公開します。モジュールをそれらのものと交換しても、モデル内の他の部分は変わりません。
なぜチューニングされたカーネルが優れているのか
「チューニングされた」と言うとき、私たちは2つの具体的なことを意味しており、どちらもトレースで確認できます。
![]() |
|---|
| Figure 13: コンパイル済み実行は、GEMM が実行される前にプリオペレーション(Dynamo、ガード、プロローグ)のコストを支払う |
![]() |
|---|
| Figure 14: Liger カーネルにはプリオペレーションがない — それらがあった場所のボックスは空 |
融合が組み込まれている。
LigerGEGLUMLPの forward はdown_proj(LigerGELUMulFunction.apply(gate_proj(x), up_proj(x)))です。LigerGELUMulFunctionは単一の Triton カーネル_geglu_tanh_forward_kernelを実行し、gelu(gate) * upを1パスで計算します。これはtorch.compileで見たものと正確に同じで、中間結果が HBM を往復することはありません。ここでは コンパイラなしで それを実現します(図13と14に示すように、Dynamo ガード、コンパイルレイテンシ、再コンパイルリスクはありません)。起動パラメータはハードウェア向けに選択されている。 カーネルはブロックサイズをランダムに推測しません。Liger の
calculate_settingsは列数からパラメータを選択します。
ここでトレードオフについて正直に述べる価値があります。生の数値は誤解を招く可能性があるからです。Liger カーネルは 92.8 µs で実行されますが、compile 実行からの Inductor の融合カーネルは 89.4 µs でした。一見すると手書きカーネルの方がやや遅く見えますが、この比較はそれを価値あるものにするコストを隠しています。
torch.compile は 静的形状 に特化します。Inductor の 89.4 µs カーネルは、まさに この特定の [8192, 3072] 問題のために生成されたからこそ高速なのです。バッチサイズ、シーケンス長、または隠れ層の次元を変更すると、Dynamo が再トレースし、新しい特化カーネルを得るためにコンパイルコストを再度支払うことになります。
したがって、本当の選択は「遅い人間カーネル vs 高速コンパイル済みカーネル」ではありません。それは 汎用性の高い高速カーネル vs 特定の入力形状に特化したカーネル です。Liger カーネルは1セットの起動パラメータを取り、任意の 形状に対して再コンパイルなしで実行します。形状ごとの特化がもたらす最後の数マイクロ秒を犠牲にする代わりに、形状の変化に対してロバストであることを得ます。
結論
以下のテーブルは、各ステップで GPU 上で何が変更され、何が変更されなかったかをまとめたものです。
| Setup | What changed | What stayed the same |
|---|---|---|
Eager nn.Linear |
ベースライン: バイアス加算はすでに GEMM エピローグ(addmm)に折り込まれているため、matmul + add ではなく 1つ の cuBLAS カーネル |
— |
Compiled nn.Linear |
いくつかの CPU ディスパッチオペレーション(aten::t ビューの簿記作業)が消える |
同じ単一の cuBLAS GEMM カーネル(バイト単位で同一)。Compile が融合するものは何もない |
| Eager MLP | 5つの GPU カーネル: 3つの GEMM + GeLU + mul。[8192, 3072] の中間結果が HBM を完全な往復する |
各 GEMM は、スタンドアロンの linear と同様に同じバイアスなし cuBLAS カーネル |
| Compiled MLP | GeLU + mul + reshape が 1つ の融合 Triton カーネルに統合される; 中間結果はレジスタに留まる。コンパイルプリオペレーション(Dynamo、ガード)を支払う | 3つの GEMM は同一の cuBLAS カーネル名で変更されない |
| Liger MLP | 同じ融合だが、Dynamo、ガード、コンパイルレイテンシ なし のハードウェアチューニング済み起動パラメータを持つ手書き Triton カーネルに組み込まれている | 3つの GEMM は依然として同じ cuBLAS カーネル |
持ち帰るべき習慣が1つあるとすれば、私たちがすべてのトレースの前に練習したものです: まず推測し、それから見る。 トレースに何が含まれると予想するかを述べ、トレースを開き、不一致を画面上で最も興味深いものとして扱います。
これは PyTorch におけるプロファイリング シリーズの2つ目の停留所でした。次回の記事では、引き続き階層を登り、この MLP ブロックからアテンションブロック、そして最終的に完全なモデルへと進んでいきます。
初期ドラフトのレビューをしてくれた Noe Flandre と Pedro Gabriel Gengo Lourenço に感謝します!
このブログ記事は LLM を使用して洗練されました。これは、エージェントをバックグラウンドで実行させてブログを生成させたことを意味するものではありません。チームの何人かは英語を母国語としない話者であり、LLM(主に英語で学習されている)が文法の小さなミスを修正したり、威圧的に聞こえない、よりクリーンな文章に言い換えたりできると考えています。「LLM が生成したなら、なぜ読む必要があるのか」という疑問に対する回答になれば幸いです。🤗














0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.