banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

JavaScriptの最適化の楽しさと利点【訳文】

antfu が推奨するインタラクティブな高品質パフォーマンス最適化ブログ記事で、著者は 20 年の経験があり、本当に多くの詳細があります。翻訳して自分の色を少し加えてみました。英語の原文を読むことを強くお勧めします。インタラクティブな体験を楽しんでください。ここではスクリーンショットで代用します。
初めての翻訳なので、至らない点があればご指摘ください!
本訳文ブログリンク:https://ysx.cosine.ren/optimizing-javascript-translate
原文リンク:https://romgrk.com/posts/optimizing-javascript
原文著者:romgrk

私はよく、一般的な JavaScript コードが以前よりもずっと遅く実行されると感じます。その理由は簡単で、適切な最適化が行われていないからです。以下は、私が発見した一般的な最適化技術のまとめです。注意すべきは、パフォーマンスと可読性のトレードオフはしばしば可読性に偏るため、いつパフォーマンスを選び、いつ可読性を選ぶかは読者自身が解決すべき問題です。また、最適化について話す際にはベンチマークテストについても触れる必要があります。もしある関数の最初の実行時間が実際の総実行時間のごく一部に過ぎない場合、その関数を数時間かけて微細に最適化して 100 倍速くすることは無意味です。最適化を行う場合、最も重要な第一歩はベンチマークテストです。このテーマについては後の内容で紹介します。また、マイクロベンチマーク(microbenchmarks)には通常欠陥があることにも注意が必要です。ここで紹介する内容もその中に含まれる可能性があります。これらのポイントを盲目的に適用しないように、ベンチマークテストなしでの適用は避けてください

私はすべての可能な状況に対して実行可能な例を提供しました。これらは私のマシン(archlinux 上の brave 122)で得られた結果をデフォルトで表示していますが、あなた自身で実行することもできます。言いたくはありませんが、Firefoxは最適化のゲームにおいてやや遅れをとっており、現在はトラフィックのごく一部を占めているため、Firefox 上の結果を有用な指標として使用することはお勧めしません。

0. 不必要な作業を避ける#

これは明らかに聞こえるかもしれませんが、ここで言及する必要があります。最適化の第一歩は不必要な作業を避けることを最初に考慮すべきです。これには、メモ化(memoization)、遅延計算(laziness)、増分計算(incremental computation)などの概念が含まれます。具体的な適用は文脈によって異なります。たとえば、React では、memo()useMemo()、およびその他の適用可能な原語を使用することを意味します。

1. 文字列比較を避ける#

JavaScript は文字列比較の実際のコストを隠すのが容易です。C 言語で文字列を比較する必要がある場合、strcmp(a, b)関数を使用します。しかし、JavaScript は===を使用して比較するため、strcmpは見えません。しかし、それは存在し、strcmpは通常(ただし常にではない)文字列の各文字を別の文字列の文字と比較する必要があります。文字列比較の時間計算量はO(n)です。避けるべき一般的な JavaScript パターンは、文字列を列挙(strings-as-enums)として使用することです。しかし、TypeScript の登場により、列挙型はデフォルトで整数であるため、これを簡単に避けることができるはずです。

// No
enum Position {
  TOP    = 'TOP',
  BOTTOM = 'BOTTOM',
}

// Yeppers
enum Position {
  TOP,    // = 0
  BOTTOM, // = 1
}

以下はコスト比較です:

// 1. 文字列比較
const Position = {
  TOP: 'TOP',
  BOTTOM: 'BOTTOM',
}
 
let _ = 0
for (let i = 0; i < 1000000; i++) {
  let current = i % 2 === 0 ?
    Position.TOP : Position.BOTTOM
  if (current === Position.TOP)
    _ += 1
}

// 2. 整数比較
const Position = {
  TOP: 0,
  BOTTOM: 1,
}
 
let _ = 0
for (let i = 0; i < 1000000; i++) {
  let current = i % 2 === 0 ?
    Position.TOP : Position.BOTTOM
  if (current === Position.TOP)
    _ += 1
}

比較結果

2. 異なる形状を避ける#

JavaScript エンジンは、オブジェクトが特定の形状を持っていると仮定し、関数が同じ形状のオブジェクトを受け取ることを前提にコードを最適化しようとします。これにより、その形状のすべてのオブジェクトのキーを一度に保存し、別のフラットな配列に値を保存することができます。JavaScript コードで表現すると次のようになります:

// エンジンが受け入れるオブジェクト
const objects = [
  {
    name: 'Anthony',
    age: 36,
  },
  {
    name: 'Eckhart',
    age: 42
  },
]

// 最適化された内部ストレージ構造は次のようになります
const shape = [
  { name: 'name', type: 'string' },
  { name: 'age',  type: 'integer' },
]
 
const objects = [
  ['Anthony', 36],
  ['Eckhart', 42],
]

Note

この概念を説明するために「shape」という言葉を使用しましたが、エンジンによっては「hidden class」や「map」と呼ばれることもあることに注意してください。

たとえば、実行時に以下の関数が形状{ x: number, y: number }の 2 つのオブジェクトを受け取ると、エンジンは将来のオブジェクトが同じ形状を持つと推測し、その形状に最適化された機械コードを生成します。

function add(a, b) {
  return {
    x: a.x + b.x,
    y: a.y + b.y,
  }
}

もし渡すオブジェクトが形状{ x, y }ではなく形状{ y, x }であれば、エンジンはその推測を撤回する必要があり、関数は突然かなり遅くなります。ここでの説明は控えますが、詳細を知りたい場合はmraleph の優れた記事を読むべきです。しかし、V8 エンジンには特に 3 つのモードがあり、アクセスに使用されます:単一形状(monomorphic)、多様形状(polymorphic)、および巨大形状(megamorphic)。あなたが本当に単一形状を維持したいのであれば、パフォーマンスの低下が非常に激しいため、注意が必要です:

訳者注:
コードのパフォーマンスを単一形状モードで維持するためには、関数に渡すオブジェクトが同じ形状を保つことを確認する必要があります。JavaScript および TypeScript の開発過程では、オブジェクトを定義および操作する際に、プロパティの追加順序を一貫させ、随意にプロパティを追加または削除しないようにすることで、V8 エンジンがオブジェクトプロパティへのアクセスを最適化できるようにする必要があります。
パフォーマンスを向上させるためには、実行時にオブジェクトの形状を変更しないように努めるべきです。これには、プロパティの追加や削除を避けること、または異なる順序で同じプロパティを持つオブジェクトを作成することが含まれます。オブジェクトプロパティの一貫性を維持することで、JavaScript エンジンがオブジェクトへの効率的なアクセスを維持でき、形状の変化によるパフォーマンスの低下を回避できます。

// セットアップ
let _ = 0

// 1. 単一形状
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { a: 1, b: _, c: _, d: _, e: _ } // すべての形状が等しい

// 2. 多様形状
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { b: _, a: 1, c: _, d: _, e: _ } // この形状は異なる

// 3. 巨大形状
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { b: _, a: 1, c: _, d: _, e: _ }
const o3 = { b: _, c: _, a: 1, d: _, e: _ }
const o4 = { b: _, c: _, d: _, a: 1, e: _ }
const o5 = { b: _, c: _, d: _, e: _, a: 1 } // すべての形状が異なる

// テストケース
function add(a1, b1) {
  return a1.a + a1.b + a1.c + a1.d + a1.e +
         b1.a + b1.b + b1.c + b1.d + b1.e }
 
let result = 0
for (let i = 0; i < 1000000; i++) {
  result += add(o1, o2)
  result += add(o3, o4)
  result += add(o4, o5)
}

比較結果

では、どうすればよいのでしょうか?

言うは易く行うは難し:すべてのオブジェクトを完全に同じ形状で作成します。React コンポーネントの props を異なる順序で記述するような微細なことでも、この状況を引き起こす可能性があります

たとえば、ここに私が React のコードベースで見つけた簡単なケースがありますが、数年前には同じ問題に対してより大きな影響を与えました。なぜなら、整数でオブジェクトを初期化し、その後浮動小数点数を保存したからです。 はい、型を変更することも形状を変えることになります。 はい、整数と浮動小数点数の型は number の背後に隠れています。それを処理してください。

Note

エンジンは通常、整数を値としてエンコードできます。たとえば、V8 は 32 ビットの値を、整数としてコンパクトなSMI(SMall)値として表現しますが、浮動小数点数と大きな整数はポインタとして渡され、文字列やオブジェクトと同様です。JSC は 64 ビットエンコーディングを使用し、ダブルタグで、すべての数字を値として渡し、残りはポインタとして渡します。


訳者注:
このエンコーディング方式により、JavaScript エンジンはパフォーマンスを犠牲にすることなく、さまざまなタイプの数字を効率的に処理できます。小さな整数に対しては、SMI の使用がヒープメモリの割り当てを減らし、操作の効率を向上させます。より大きな数字に対しては、ポインタを介してアクセスする必要がありますが、この方法でも JavaScript がさまざまな数字タイプを処理する際の柔軟性と効率を確保できます。
このような設計は、特に動的型付け言語において、データ表現とパフォーマンス最適化の間のバランスを取る知恵を反映しています。この方法により、エンジンは JavaScript コードを実行する際に、メモリ使用量を可能な限り減らし、計算速度を向上させることができます。

3. 配列 / オブジェクトメソッドの使用を避ける#

私も他の人と同様に関数型プログラミングが好きですが、Haskell / OCaml / Rust で作業していない限り、関数型コードは高効率な機械コードにコンパイルされないため、関数型は常に命令型よりも遅くなります。

const result =
  [1.5, 3.5, 5.0]
    .map(n => Math.round(n))
    .filter(n => n % 2 === 0)
    .reduce((a, n) => a + n, 0)

これらのメソッドの問題は次のとおりです:

  1. 完全な配列のコピーを作成する必要があり、そのコピーは後でガベージコレクタによって解放される必要があります。メモリ I/O の問題については第 5 節で詳しく説明します。
  2. N 個の操作のために N 回ループを実行しますが、for ループは 1 回だけループを許可します。
// セットアップ:
const numbers = Array.from({ length: 10_000 }).map(() => Math.random())

// 1. 関数型
const result =
  numbers
    .map(n => Math.round(n * 10))
    .filter(n => n % 2 === 0)
    .reduce((a, n) => a + n, 0)

// 2. 命令型
let result = 0
for (let i = 0; i < numbers.length; i++) {
  let n = Math.round(numbers[i] * 10)
  if (n % 2 !== 0) continue
  result = result + n
}

比較結果

Object.values()Object.keys()、およびObject.entries()のようなオブジェクトメソッドも同様の問題に直面します。これらもより多くのデータを割り当てるため、メモリアクセスはすべてのパフォーマンス問題の根源です。私は誓いますが、第 5 節でお見せします。

4. 間接的なソースを避ける#

最適化の利益を探すもう一つの場所は、間接的なソースです。私は 3 つの主要なソースを見ています:

const point = { x: 10, y: 20 }
 
// 1. プロキシオブジェクトは最適化が難しいです。なぜなら、get/set関数がカスタムロジックを実行している可能性があるため、エンジンは通常の仮定を行うことができません。
const proxy = new Proxy(point, { get: (t, k) => { return t[k] } })
// 一部のエンジンはプロキシコストを消失させることができますが、これらの最適化のコストは非常に高く、壊れやすいです。
const x = proxy.x
 
// 2. 通常は見落とされがちですが、`.`または`[]`でオブジェクトにアクセスすることも間接的なアクセスです。単純な場合、エンジンはコストを最適化できる可能性が高いです:
const x = point.x
// しかし、追加のアクセスはコストを増加させ、「point」の状態に対するエンジンの仮定を難しくします:
const x = this.state.circle.center.point.x
 
// 3. 最後に、関数呼び出しもコストを生じさせます。エンジンは通常、これらの関数をインライン化するのが得意です:
function getX(p) { return p.x }
const x = getX(p)
// しかし、必ずしも保証されるわけではありません。特に、関数呼び出しが静的関数からではなく、パラメータなどから来る場合:
function Component({ point, getX }) {
  return getX(point)
}

プロキシのベンチマークテストは現在、特に V8 で非常に厳しいです。私が最後に確認したとき、プロキシオブジェクトは常に JIT からインタプリタに戻っていました。これらの結果から、状況は今も変わっていない可能性があります。

// 1. プロキシアクセス
const point = new Proxy({ x: 10, y: 20 }, { get: (t, k) => t[k] })
 
for (let _ = 0, i = 0; i < 100_000; i++) { _ += point.x }


// 2. 直接アクセス
const point = { x: 10, y: 20 }
const x = point.x
 
for (let _ = 0, i = 0; i < 100_000; i++) { _ += x }

比較結果

私はまた、深くネストされたオブジェクトへのアクセスと直接アクセスの比較を示したいと思いましたが、エンジンはホットループと定数オブジェクトが存在する場合にエスケープ分析を通じてオブジェクトアクセスを最適化するのが得意です。この状況を防ぐために、いくつかの間接的な方法を挿入しました。

訳者注:
「ホットループ」(hot loop)とは、プログラム実行中に頻繁に実行されるループ、つまり大量に繰り返し実行されるコード部分を指します。この部分のコードは実行回数が非常に多いため、プログラムのパフォーマンスに与える影響が特に顕著であり、パフォーマンス最適化の重要なポイントとなります。後文でも登場します。

// 1. ネストされたアクセス
const a = { state: { center: { point: { x: 10, y: 20 } } } }
const b = { state: { center: { point: { x: 10, y: 20 } } } }
const get = (i) => i % 2 ? a : b
 
let result = 0
for (let i = 0; i < 100_000; i++) {
  result = result + get(i).state.center.point.x }


// 2. 直接アクセス
const a = { x: 10, y: 20 }.x
const b = { x: 10, y: 20 }.x
const get = (i) => i % 2 ? a : b
 
let result = 0
for (let i = 0; i < 100_000; i++) {
  result = result + get(i) }

比較結果

5. キャッシュミスを避ける#

この点は少し低レベルの知識を必要としますが、JavaScript でも影響がありますので、説明します。CPU の観点から見ると、RAM からメモリを取得する速度は非常に遅いです。速度を上げるために、主に 2 つの最適化方法を使用します。

5.1 プリフェッチ(Prefetching)#

最初の方法はプリフェッチです:より多くのメモリを事前に取得し、そのメモリがあなたが興味を持っているものであることを期待します。メモリアドレスを要求すると、次のメモリ領域にも興味があると常に推測します。したがって、データを順番にアクセスすることが重要です。以下の例では、ランダムな順序でメモリにアクセスする影響を観察できます。

// セットアップ:
const K = 1024
const length = 1 * K * K
 
// これらの点は一つずつ作成されるため、メモリ内で順番に割り当てられます。
const points = new Array(length)
for (let i = 0; i < points.length; i++) {
  points[i] = { x: 42, y: 0 }
}
 
// この配列は上と同じデータを含みますが、ランダムにシャッフルされています。
const shuffledPoints = shuffle(points.slice())


// 1. 順序のあるアクセス
let _ = 0
for (let i = 0; i < points.length; i++) { _ += points[i].x }


// 2. ランダムなアクセス
let _ = 0
for (let i = 0; i < shuffledPoints.length; i++) { _ += shuffledPoints[i].x }

比較オブジェクト

では、どうすればよいのでしょうか?

この概念を実践に適用するのは最も難しいかもしれません。JavaScript にはオブジェクトがメモリ内の位置を指定する方法がありませんが、上の例のようにデータを操作する前に並べ替えやソートを行うことで、これらの知識を活用することができます。作成されたオブジェクトがしばらくの間同じ位置に留まるとは限りません。なぜなら、ガベージコレクタがそれらを移動させる可能性があるからです。しかし、例外として、数値配列、特にTypedArrayインスタンスが最適です:

より詳細な例については、こちらのリンクを参照してください。*。

  • 注意:いくつかの過去の最適化が含まれていますが、全体としては依然として正確です。

訳者注:
JavaScript のTypedArrayインスタンスは、バイナリデータを処理するための効率的な方法を提供します。通常の配列と比較して、TypedArray はメモリ使用量がより効率的であり、連続したメモリ領域にアクセスするため、データ操作時により高いパフォーマンスを実現できます。これはプリフェッチの概念と密接に関連しており、連続したメモリブロックを処理することは、特に大量のデータを扱う場合にランダムアクセスメモリよりも速いです。
たとえば、大量の数値データを処理する場合、画像処理や科学計算などで、Float32Array や Int32Array などの TypedArray を使用すると、より良いパフォーマンスが得られます。これらの配列はメモリを直接操作するため、データの読み取りと書き込みがより迅速に行われ、特に順次アクセス時に効果的です。これにより、JavaScript ランタイムのオーバーヘッドが削減され、現代の CPU のプリフェッチおよびキャッシュメカニズムを利用してデータ処理を加速できます。

5.2 L1/2/3 キャッシュ#

CPU が使用する 2 つ目の最適化方法は L1/L2/L3 キャッシュです:これらのキャッシュは、より高速な RAM のようなもので、より高価ですが、その容量ははるかに小さいです。これらは RAM データを含みますが、LRU キャッシュの役割を果たします。データは「ホット」(処理中)であるときに入ります。新しい作業データがスペースを必要とするときに主 RAM に書き戻されます。したがって、ここでの重要な点は、できるだけ少ないデータを使用し、作業データセットを高速キャッシュに保持することです。以下の例では、各連続キャッシュを破壊する効果を観察できます。

// セットアップ:
const KB = 1024
const MB = 1024 * KB
 
// これらはこれらのキャッシュに適した近似サイズです。コンピュータで同じ結果が得られない場合、サイズが異なる可能性があります。
const L1  = 256 * KB
const L2  =   5 * MB
const L3  =  18 * MB
const RAM =  32 * MB
 
// すべてのテストケースで同じバッファにアクセスしますが、最初のケースでは「L1」エントリの前0から、2番目のケースでは0から「L2」エントリ、以下同様にアクセスします。
const buffer = new Int8Array(RAM)
buffer.fill(42)
 
const random = (max) => Math.floor(Math.random() * max)

// 1. L1
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L1)] }

// 2. L2
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L2)] }

// 3. L3
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L3)] }

// 4. RAM
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(RAM)] }

比較結果

では、どうすればよいのでしょうか?

削除できるデータやメモリ割り当てを無情に削除してください。データセットが小さいほど、プログラムの実行速度が速くなります。メモリ I/O は 95%のプログラムのボトルネックです。もう一つの良い戦略は、作業をチャンク(chunks)に分け、小さなデータセットを一度に処理することです。

CPU とメモリに関する詳細については、こちらのリンクを参照してください。

Note

不変データ構造について - 不変性は明確さと正確さにとって良いですが、パフォーマンスの観点からは、不変データ構造を更新することはコンテナをコピーすることを意味し、これによりより多くのメモリ I/O が発生し、キャッシュがフラッシュされます。可能な限り不変データ構造を避けるべきです。
...スプレッド演算子について - 非常に便利ですが、使用するたびにメモリ内に新しいオブジェクトを作成します。より多くのメモリ I/O が発生し、キャッシュが遅くなります!

6. 大きなオブジェクトを避ける#

第 2 節で述べたように、エンジンは形状を使用してオブジェクトを最適化します。しかし、形状が大きくなりすぎると、エンジンは通常のハッシュテーブル(Map オブジェクトなど)を使用せざるを得なくなります。第 5 節で見たように、キャッシュミスはパフォーマンスを大幅に低下させます。ハッシュテーブルは通常、データがランダムで均等に分布しているため、この問題が発生しやすいです。ユーザー ID でインデックスされたユーザーマッピングがどのように機能するかを見てみましょう。

// セットアップ:
const USERS_LENGTH = 1_000

// セットアップ:
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
  byId[id] = { id, name: 'John'}
})
let _ = 0

// 1. []アクセス
Object.keys(byId).forEach(id => { _ += byId[id].id })

// 2. 直接アクセス
Object.values(byId).forEach(user => { _ += user.id })

比較オブジェクト

オブジェクトのサイズが増加するにつれて、パフォーマンスがどのように低下するかも観察できます:

// セットアップ:
const USERS_LENGTH = 100_000

比較オブジェクト

では、どうすればよいのでしょうか?

上記のように、大きなオブジェクトに頻繁にインデックスを付けることは避けるべきです。事前にオブジェクトを配列に変換するのが最善です。ID をモデルに整理することで、データを整理し、ID を取得するためにキーのマッピングを参照する必要がなくなります。

7. eval の使用#

一部の JavaScript パターンはエンジンに最適化されにくく、eval () やその派生ツールを使用することで、これらのパターンを消失させることができます。以下の例では、eval () を使用することで、動的オブジェクトキーを使用してオブジェクトを作成するコストを回避する方法を観察できます:

// セットアップ:
const key = 'requestId'
const values = Array.from({ length: 100_000 }).fill(42)
// 1. evalなし
function createMessages(key, values) {
  const messages = []
  for (let i = 0; i < values.length; i++) {
    messages.push({ [key]: values[i] })
  }
  return messages
}
 
createMessages(key, values)


// 2. evalあり
function createMessages(key, values) {
  const messages = []
  const createMessage = new Function('value',
    `return { ${JSON.stringify(key)}: value }`
  )
  for (let i = 0; i < values.length; i++) {
    messages.push(createMessage(values[i]))
  }
  return messages
}
 
createMessages(key, values)

比較結果

訳者注:
eval を使用するバージョン(Function コンストラクタを介して実装)では、例はまず関数を作成し、その関数が動的キーと指定された値を持つオブジェクトを生成します。次に、この関数がループ内で呼び出され、メッセージ配列が作成されます。この方法は、オブジェクトを生成する関数を事前にコンパイルすることで、実行時の計算とオブジェクト作成のオーバーヘッドを減少させます。
eval および Function コンストラクタの使用は、特定の実行時オーバーヘッドを回避できますが、任意のコードを実行できるため、潜在的なセキュリティリスクも引き起こします。また、動的に評価されるコードはデバッグや最適化が難しい場合があり、JavaScript エンジンは事前に効果的な最適化を行えない可能性があります。
したがって、この例はパフォーマンス最適化のテクニックを示していますが、実際のアプリケーションでは、パフォーマンスを最適化しつつ、コードの安全性と保守性を維持する他の方法を探すことをお勧めします。ほとんどの場合、大量の動的オブジェクト作成の必要性を避け、静的分析とコードのリファクタリングを通じてパフォーマンスを向上させる方が良い選択です。

eval のもう一つの良い使用例は、フィルタ述語関数をコンパイルすることです。この関数では、実行されないことがわかっている分岐を捨てることができます。一般的に、非常にホットなループ内で実行される関数は、この最適化に適しています。

明らかに、eval () に関する一般的な警告も適用されます:ユーザー入力を信頼せず、eval () に渡されるコードの内容をクリーンアップし、XSS の可能性を生じさせないようにしてください。また、CSP を持つブラウザページなど、一部の環境では eval () へのアクセスが許可されていないことにも注意が必要です。

8. 文字列の使用に注意#

私たちはすでに、文字列が見かけよりも高価であることを見てきました。さて、ここで良いニュースと悪いニュースがあります。私は唯一の論理的な順序で発表します(悪いニュースの後に良いニュース):文字列は見かけよりも複雑ですが、非常に効率的に使用することもできます。

文字列操作は、その文脈によって JavaScript の中心部分となります。文字列を大量に使用するコードを最適化するために、エンジンは創造的でなければなりません。私が言いたいのは、彼らは使用状況に応じて、C++ のさまざまな文字列表現を使用して文字列オブジェクトを表現する必要があるということです。V8(現在最も一般的なエンジン)に適用される 2 つの一般的なケースがあります。

まず、+で連結された文字列は2 つの入力文字列のコピーを作成しません。この操作は、各サブ文字列へのポインタを作成します。TypeScript の場合は次のようになります:

class String {
  abstract value(): char[] {}
}
 
class BytesString {
  constructor(bytes: char[]) {
    this.bytes = bytes
  }
  value() {
    return this.bytes
  }
}
 
class ConcatenatedString {
  constructor(left: String, right: String) {
    this.left = left
    this.right = right
  }
  value() {
    return [...this.left.value(), ...this.right.value()]
  }
}
 
function concat(left, right) {
  return new ConcatenatedString(left, right)
}
 
const first = new BytesString(['H', 'e', 'l', 'l', 'o', ' '])
const second = new BytesString(['w', 'o', 'r', 'l', 'd'])
 
// 見てください、配列のコピーはありません!
const message = concat(first, second)

次に、文字列スライス(string slices)もコピーを作成する必要はありません:それらは単に別の文字列の範囲を指すことができます。上記の例を続けます:

class SlicedString {
  constructor(source: String, start: number, end: number) {
    this.source = source
    this.start = start
    this.end = end
  }
  value() {
    return this.source.value().slice(this.start, this.end)
  }
}
 
function substring(source, start, end) {
  return new SlicedString(source, start, end)
}
 
// これは「He」を表しますが、まだ配列のコピーは含まれていません。
// これはConcatenatedStringから2つのBytesStringへのSlicedStringです
const firstTwoLetters = substring(message, 0, 2)

しかし、問題は、これらのバイトを変更し始めると、コピーコストが発生することです。私たちの String クラスに戻り、.trimEndメソッドを追加しようとすると仮定しましょう:

class String {
  abstract value(): char[] {}
 
  trimEnd() {
    // `.value()`はここで私たちのSliced->Concatenated->2*Bytes文字列を呼び出す可能性があります!
    const bytes = this.value()
 
    const result = bytes.slice()
    while (result[result.length - 1] === ' ')
      result.pop()
    return new BytesString(result)
  }
}

したがって、突変操作(mutation)を使用することと、連結操作(concatenation)を使用することを比較する例にジャンプしましょう:

// セットアップ:
const classNames = ['primary', 'selected', 'active', 'medium']

// 1. 突変
const result =
  classNames
    .map(c => `button--${c}`)
    .join(' ')

// 2. 連結
const result =
  classNames
    .map(c => 'button--' + c)
    .reduce((acc, c) => acc + ' ' + c, '')

比較結果

では、どうすればよいのでしょうか?

一般的に、突変を可能な限り避けるべきです。これには.trim().replace()などのメソッドが含まれます。これらのメソッドの使用を避ける方法を考慮してください。一部のエンジンでは、文字列テンプレートが+よりも遅い場合があります。現在の V8 ではそうですが、将来的にはそうでないかもしれませんので、ベンチマークに基づいて判断してください。

上記のSlicedStringについて注意すべき点は、大きな文字列の小さな部分文字列がメモリにまだ存在する場合、大きな文字列のガベージコレクタの収集を妨げる可能性があることです!したがって、大きなテキストを処理し、小さな文字列を抽出する場合、大量のメモリをリークする可能性があります。

const large = Array.from({ length: 10_000 }).map(() => 'string').join('')
const small = large.slice(0, 50)
//    ^ will keep  alive

ここでの解決策は、私たちに有利な突変メソッドの 1 つを使用することです。もし私たちが small にそのメソッドの 1 つを使用すれば、それは強制的にコピーを行い、large を指す古いポインタは失われます:

// 存在しないトークンを置き換える
const small = small.replace('#'.repeat(small.length + 1), '')

詳細については、V8 の string.hまたはJavaScriptCore の JSString.hを参照してください。

Note

文字列の複雑性について - 私は迅速にいくつかのことを見てきましたが、文字列の複雑性を増す実装の詳細がまだたくさんあります。各文字列表現には通常、最小の長さがあります。たとえば、非常に小さな文字列には連結文字列が使用されない場合があります。時には、部分文字列を指す部分文字列を避ける制限もあります。上記のリンクの C++ ファイルを読むことで、実装の詳細を理解するのに役立ちます。

9. 専門化(specialization#

パフォーマンス最適化の重要な概念の 1 つは専門化(specialization)です:特定のユースケースの制約に合わせてロジックを調整すること。これは通常、どの状況が_発生する可能性があるかを理解し、それに基づいてコーディングすることを意味します。

たとえば、私たちは時々製品リストにタグを追加する必要がある商人だと仮定します。経験上、タグは通常空であることがわかっています。この情報を理解することで、私たちはこの状況に特化した関数を設計できます:

// セットアップ:
const descriptions = ['apples', 'oranges', 'bananas', 'seven']
const someTags = {
  apples: '::promotion::',
}
const noTags = {}
 
// 製品を文字列に変換し、適用可能であればそのタグも含める
function productsToString(description, tags) {
  let result = ''
  description.forEach(product => {
    result += product
    if (tags[product]) result += tags[product]
    result += ', '
  })
  return result
}
 
// 今、特化します
function productsToStringSpecialized(description, tags) {
  // `tags`が空である可能性が高いので、最初に1回チェックし、その後内側のループから`if`チェックを削除できます
  if (isEmpty(tags)) {
    let result = ''
    description.forEach(product => {
      result += product + ', '
    })
    return result
  } else {
    let result = ''
    description.forEach(product => {
      result += product
      if (tags[product]) result += tags[product]
      result += ', '
    })
    return result
  }
}
function isEmpty(o) { for (let _ in o) { return false } return true }
 
// 1. 専門化されていない
for (let i = 0; i < 100; i++) {
  productsToString(descriptions, someTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
}


// 2. 専門化された
for (let i = 0; i < 100; i++) {
  productsToStringSpecialized(descriptions, someTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
}

比較結果

この最適化は、適度な改善をもたらすことがありますが、これらの改善は蓄積されます。これらは、形状やメモリ I/O などのより重要な最適化の良い補完です。しかし、条件が変わると、専門化が逆効果になる可能性があるため、適用する際には注意が必要です。

Note

分岐予測と無分岐予測コード - コードから分岐を削除することで、パフォーマンスが大幅に向上する可能性があります。分岐予測器に関する詳細は、stackoverflow の古典的な回答を読むと良いでしょう:なぜソートされた配列を処理する方がソートされていない配列を処理するよりも速いのか?

10. データ構造#

データ構造の詳細については、別の記事を書く必要があるため、あまり多くは述べません。しかし、正しくないデータ構造を使用することがあなたのユースケースに与える影響は、上記のいかなる最適化よりも大きい可能性があります。Map や Set などのネイティブデータ構造に精通し、リンクリスト、優先キュー、木(RB および B+)、およびトライを理解することをお勧めします。

ただし、迅速な例として、Array.includesSet.hasのパフォーマンスを小さなリストで比較してみましょう:

// セットアップ:
const userIds = Array.from({ length: 1_000 }).map((_, i) => i)
const adminIdsArray = userIds.slice(0, 10)
const adminIdsSet = new Set(adminIdsArray)
// 1. 配列
let _ = 0
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsArray.includes(userIds[i])) { _ += 1 }
}
// 2. Set
let _ = 0
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsSet.has(userIds[i])) { _ += 1 }
}

比較結果

ご覧のとおり、データ構造の選択は非常に大きな影響を与えます。

実際の例として、私は次のようなシナリオに遭遇しました:配列をリンクリストに置き換えることで、ある関数の実行時間を 5 秒から 22 ミリ秒に短縮しました

11. ベンチマークテスト(Benchmarking)#

この部分を最後に残した理由は 1 つだけです:上記の面白い部分で信頼性を確立する必要がありました。今、私はそれを習得しました(そう願っています)。ベンチマークテストは最適化作業の最も重要な部分です。それは最も重要であるだけでなく、非常に難しいです。20 年の経験があっても、私は時々欠陥のあるベンチマークテストを作成したり、分析ツールを誤って使用したりします。したがって、どんな場合でも、正しくベンチマークテストを作成するために最善を尽くしてください。

11.0 自頂向下#

あなたの最優先事項は常に、実行時間の最大部分を占める関数 / コードセクションを最適化することです。最も重要な部分以外の最適化に時間を費やすことは、時間の無駄です。

11.1 マイクロベンチマーク(micro-benchmarks)を避ける#

プロダクションモードでコードを実行し、これらの観察結果に基づいて最適化します。JS エンジンは非常に複雑で、マイクロベンチマークでのパフォーマンスは実際のアプリケーションシナリオとは異なることがよくあります。たとえば、このマイクロベンチマークを見てみましょう:

const a = { type: 'div', count: 5, }
const b = { type: 'span', count: 10 }
 
function typeEquals(a, b) {
  return a.type === b.type
}
 
for (let i = 0; i < 100_000; i++) {
  typeEquals(a, b)
}

少し注意を払えば、エンジンは形状{ type: string, count: number }の関数を特化させることがわかります。しかし、実際のアプリケーションでは、a と b は常にこの形状であるのか、それとも他の形状を受け取るのか?プロダクションで多くの形状を受け取る場合、この関数の動作は異なるでしょう。

11.2 結果に疑問を持つ#

もしあなたが関数を最適化したばかりで、それが以前よりも 100 倍速くなった場合、それを疑ってください。結果を覆そうとし、プロダクションモードで実行し、ストレステストを行ってください。同様に、あなたのツールにも疑問を持ってください。devtools を使用してベンチマークテストを観察するだけで、その動作が変わる可能性があります。

11.3 目標を選択する#

異なるエンジンは、特定のパターンに対して異なる最適化効果を持っています。あなたは、あなたに関連するエンジンに対してベンチマークテストを行い、どのエンジンが重要かを優先するべきです。これは Babel の実際の例で、V8 を改善することは JSC のパフォーマンスを低下させることを意味しました。

12. 分析とツール#

分析と開発ツールに関するさまざまな議論。

12.1 ブラウザの罠#

ブラウザで分析を行う場合、使用しているブラウザプロファイルがクリーンで空白であることを確認してください。そのために、私は別のブラウザを使用することさえあります。分析中にブラウザ拡張機能を有効にしていると、測定結果が乱れる可能性があります。特にReact devtoolsは結果に大きな影響を与え、コードのレンダリング速度が実際にユーザーに提示されるよりも遅く見えることがあります。

12.2 サンプルと構造分析#

ブラウザのパフォーマンス分析ツールはサンプリングベースのアナライザーであり、定期的に呼び出しスタックをサンプリングします。これには大きな欠点があります:非常に小さく頻繁に呼び出される関数は、これらのサンプリング間隔で呼び出される可能性があり、見えるスタックグラフで大幅に過小評価される可能性があります。Firefox devtools でカスタムサンプリング間隔を設定するか、CPU スロットリング機能を持つ Chrome devtools を使用してこの問題を軽減できます。

12.3 パフォーマンス最適化における一般的なツール#

通常のブラウザ devtools に加えて、これらの設定オプションを理解することが役立つかもしれません:

  • Chrome devtools には、遅い原因を特定するのに役立つ多くの実験的フラグがあります。ブラウザでスタイル / レイアウトの再計算をデバッグする必要がある場合、スタイル無効トラッカーが非常に役立ちます。
    https://github.com/iamakulov/devtools-perf-features
  • deoptexplorer-vscode 拡張を使用すると、V8/chromium のログファイルを読み込んで、コードが最適化をトリガーするタイミングを理解できます。異なる形状を関数に渡すときに、ログファイルを読むだけで済みますが、体験をより楽しくします。
    https://github.com/microsoft/deoptexplorer-vscode
  • 各 JS エンジンのデバッグシェルをコンパイルして、どのように機能しているかをより詳細に理解することもできます。これにより、perf や他の低レベルツールを実行し、各エンジンが生成するバイトコードや機械コードを確認できます。
    V8 の例 | JSC の例 | SpiderMonkey の例(欠落)
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。