mizdra's blog

ぽよぐらみんぐ

Twitter に投稿したツイートを Mastodon に転送するようにした

去年の 11 月から続く一連の騒動を受けて、id:mizdra のフォロワーの中でも Twitter から Fediverse に移行してきている人が増えてきた。僕自身は移行するつもりはないけれど、移行したフォロワーが僕のツイートを Fediverse から見れるように、ツイートを Mastodon へと転送するようにしてみた。せっかくなので、そのやり方について書き残しておく。

作戦

IFTTT という「〇〇したらXXする」みたいなピタゴラスイッチをボタンポチポチで作れるサービスがある。これを使い、当該 Twitter アカウントでツイートがされたら、それを契機に Mastodon にトゥートを投稿する、というピタゴラスイッチを組むことにする *1

転送する上での注意点 (2023/4/10 追記)

(トラバで情報を頂いたので追記)

今回紹介する方法では、普段は自動投稿のみをする BOT のようなものを作ることになる。しかし Mastodon インスタンスによっては、BOT の運用方法を規制するルールがあるそうだ。例えば、日本国内で著名な Mastodon インスタンスである mstdn.jp では、BOT からの Public なトゥートは禁止されている。mstdn.jp で今回紹介する方法を利用するなら、トゥートを Unlisted (ローカルタイムラインや連合タイムラインには表示せず、ホームタイムラインにのみ表示) にする必要がある。

bering.hatenadiary.com

インスタンスによってルールも異なるので、本記事で紹介している設定に加えて追加でいくつか設定が必要になるかもしれない。あるいは、そもそも自動転送やマルチポストといったものが禁止されている可能性もある。転送設定をする前にインスタンスのルールを確認するように。

ステップ1. Mastodon でアクセストークンを発行する

IFTTT から Mastodon にトゥートを投稿するには、Mastodon の「アクセストークン」と呼ばれるものが必要なので、まずはその作成から。

Mastodon のアクセストークンは、Mastodon のメニューの ユーザー設定 > 開発 > 新規アプリ から作成できる。

アクセストークンを発行するページの開き方の図解

するとアクセストークンの発行のために必要な情報の入力を求める画面が出てくる。今回は以下の内容を設定して「送信」ボタンを押せば良い。

  • アプリの名前: twitter2mastodon など
    • 発行したアクセストークンが、どんな目的で使われているのかあとから区別できるよう、名前を付けておくための欄
    • 好きな名前を入れれば良い。id:mizdra は「twitter2mastodon」にした。
  • アプリのウェブサイト: デフォルトのままで OK
  • リダイレクトURI: デフォルトのままで OK
  • アクセス権: write:media, write:status だけチェックを入れて、他のチェックは全部外す
    • 発行されるアクセストークンを使ってできることをここで制限できる
    • デフォルトで read, write, follow にチェックが入っているが、今回は要らないので外す
    • 画像の投稿と、テキストの投稿ができるよう、write:media, write:status にチェックを入れる

「送信」ボタンを押すと「アプリが作成されました」、という表示が出てくるはず。アプリ名のところをクリックすると、生成されたアクセストークンを確認できる。

生成されたアクセストークンの確認方法

発行したアクセストークンは後のステップで使うので、どこかにメモしておく。ただしこのアクセストークンが他の人に漏れてしまうと、他の人が自分になりすましてトゥートできてしまうので、取り扱うには十分注意するように。

ステップ2. IFTTT のアカウントを作成する

https://ifttt.com/join から IFTTT のアカウントを作成する。流れでできるはずなので説明は割愛。もうアカウントを持っている人はスキップで OK。

ステップ3. IFTTT で「Twitter でツイートしたら Mastodon に転送する」Applet を作る

IFTTT では「Applet」というものを作って、ピタゴラスイッチのような自動化フローを組んでいく。今回もこの Applet を作る必要がある。IFTTT のページの右上にある「Create」ボタンから作成できる。

Applet の作成画面が出てくるので、以下のような手順で Applet を作っていく:

  1. 「If This」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Twitter」を探して選択
    2. Twitter のどのイベントを対象にピタゴラスイッチを起動させるか聞かれるので、「New tweet by you (新しくツイートをした時)」を選択
    3. 「Add new account」から自分の Twitter アカウントと IFTTT を連携
    4. 「Include」欄のうち、「retweets」にチェックを入れる
    5. 入力内容に間違いがないか確認して、「Create trigger」をクリック
      • 以下のようになっていれば OK
      • 「Twitter account」が設定されていて、「retweets」にチェックが入っていればOK
  2. 1 の画面に戻ってくるので、「Then that」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Webhooks」を探して選択
    2. 「Make a web request」を選択
    3. フォームが出てくるので以下の情報を入力
      • URL: https://<お使いの Mastodon インスタンスのドメイン>/api/v1/statuses
      • Method: POST
      • Content Type: application/x-www-form-urlencoded
      • Additional Headers: 空で OK
      • Body: access_token=<ステップ1でコピーしたアクセストークン>&status={{Text}}
    4. 入力内容に間違いがないか確認して、「Create action」をクリック
      • 画像のような内容になっていれば問題ない
  3. また 1 の画面に戻ってくるので、「Continue」をクリック
  4. Applet のタイトルを設定するよう指示されるので、Twitter => Mastodon などと入力
  5. 「Finish」を押して Applet 作成完了

これで Twitter でツイートしたら Mastodon に転送されるようになったはず。

ステップ4: Mastodon アカウントを BOT アカウントとしてマークする (オプション)

Twitter のツイートをトゥートする Mastodon アカウントは、いわば Bot である。そういう BOT のトゥートを見たくない人もいるので、その配慮として Mastodon アカウントを BOT としてマークしておくと良い (とブコメで教えていただいた。ありがとうございます。)。

BOT アカウントのマークは ユーザー設定 > プロフィール > 外観 にある 「これはBOTアカウントです」にチェックを入れればできる。

「これはBOTアカウントです」にチェックを入れればOK

あとお好みで ユーザー設定 > ユーザー設定 > その他 から「検索エンジンによるインデクスを拒否する」にチェックを入れたり、「投稿の公開範囲」を 未収載 に設定してみたりすると良いかも。Mastodon のアカウントの表示名を <ユーザ名>@twitter.com みたいにして、マルチポストだと分かりやすくするのも良いと思う。

ひとまずこれですべての設定が完了したはず。お疲れさまでした。

余談: id:mizdra が Mastodon に転送しようと思った背景

id:mizdra は情報収取ツールとして Twitter を利用している。Twitter さえ見ていれば欲しい情報が手に入るように、情報を発信する人を積極的にフォローしている *2。同じように情報収集目的で Twitter を利用している人は大勢居るはずで、おそらく @mizdra のフォロワーにも結構居るのではないかと思っている。そうした人が Fediverse に移行したとき、移行先の Fediverse でも情報収集のために、もともと Twitter でフォローしていた人を、Fediverse でフォローしたくなってくる。しかしご存知の通り、Twitter ユーザを Fediverse からフォローすることはできない。

これは Fediverse に移行する以上仕方のないことだと思う。ただ、同じ情報収取目的で SNS をやっている身としては、少し心が痛む。

id:mizdra が Twitter に投稿したツイートを Mastodon に転送するようにしたのは、少しでもそうした人の助けになればと思ったから。少なくとも僕が Twitter を情報収取目的で使っている以上、同じように情報収集目的で SNS をやっているフォロワーには不便させたくない。

*1:IFTTT の Twitter 連携が突然動かなったらこの方法機能しなくなってしまうけど、まあもしそうなったらその時なんとかするスタイルで。

*2:詳しくは: 個人的 Web フロントエンドスキルの獲得方法 - mizdra's blog

Next.js の getServerSideProps を共通化する

Next.js で開発していると、複数のページの getServerSidePropsで同じようなコードを度々書くことになると思う。例えば 「GraphQL クライアントでクエリを fetch して、そのレスポンスをpagePropsに焼き込むロジック」であったり、「(マルチテナントアプリケーションにおいて) リクエストヘッダーからテナントを特定するロジック」であったり。こうした大部分のページで使われるロジックのコードは、何度も書かずに済むよう、何らかの共通化をしたくなる。

すでに色々な人が似たような動機でgetServerSidePropsを共通化する方法を紹介していると思う。それを参考にしながら id:mizdra も自分流の共通化方法を実践している。何度か Next.js アプリケーションを作っているうちに、自分の型のようなものが身についてきたので、それを紹介してみる。

はじめに結論

こう書いてます。

// lib/get-server-side-props.ts
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';

type TenantName = 'Tenant-A' | 'Tenant-B' | 'Tenant-C';

// getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義しておく
declare function getTenantNameFromHostHeader(context: GetServerSidePropsContext): TenantName | undefined;
export declare function createApolloClient(
  cache: NormalizedCacheObject | undefined,
  tenantName: TenantName,
  context?: GetServerSidePropsContext,
): ApolloClient<NormalizedCacheObject>;

/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` に渡される context の型 */
type WithTenantOuterContext = GetServerSidePropsContext;
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` に渡される context の型 */
type WithTenantInnerContext = WithTenantOuterContext & { tenantName: TenantName };
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` から返すべき pageProps の型 */
type WithTenantInnerPageProps = {};
/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` から返すべき pageProps の型 */
export type WithTenantOuterPageProps = WithTenantInnerPageProps & { tenantName: TenantName };

/**
 * Host ヘッダーからテナント名を取得し、innerGSSP にテナント名を渡す HoF。
 * また、テナント名をコンポーネントから参照できるよう、pageProps に焼き込む。
 */
function withTenantGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithTenantInnerContext) => ReturnType<GetServerSideProps<P & WithTenantInnerPageProps>>,
): (context: WithTenantOuterContext) => ReturnType<GetServerSideProps<P & WithTenantOuterPageProps>> {
  return async (context) => {
    const tenantName = getTenantNameFromHostHeader(context);
    if (tenantName === undefined) throw new Error('Host ヘッダーからテナントの特定に失敗しました。');
    const innerResult = await innerGSSP({ ...context, tenantName });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        tenantName,
      },
    };
  };
}

type WithApolloOuterContext = GetServerSidePropsContext & { tenantName: TenantName };
type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> };
type WithApolloInnerPageProps = {};
export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject };

/**
 * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。
 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。
 */
function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>,
): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> {
  return async (context) => {
    const apolloClient = createApolloClient(undefined, context.tenantName, context);
    const innerResult = await innerGSSP({ ...context, apolloClient });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        initialApolloState: apolloClient.cache.extract(),
      },
    };
  };
}

export type WithGlobalOuterContext = GetServerSidePropsContext;
export type WithGlobalInnerContext = WithTenantInnerContext & WithApolloInnerContext;
export type WithGlobalInnerPageProps = WithTenantInnerPageProps & WithApolloInnerPageProps;
export type WithGlobalOuterPageProps = WithTenantOuterPageProps & WithApolloOuterPageProps;
/** よく使われる HoF を合成した HoF */
export function withGlobalGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithGlobalInnerContext) => ReturnType<GetServerSideProps<P & WithGlobalInnerPageProps>>,
): (context: WithGlobalOuterContext) => ReturnType<GetServerSideProps<P & WithGlobalOuterPageProps>> {
  return withTenantGSSP(withApolloGSSP(innerGSSP));
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithTenantOuterPageProps, WithApolloOuterPageProps } from '../lib/get-server-side-props';

// withGlobalGSSP などで囲われていれば、OuterPageProps 相当のプロパティが pageProps に入っているはず。
// ただしページによっては withGlobalGSSP などで囲ってなかったりするので、 Partial<> で optional にする
type CustomAppProps = AppProps<Partial<WithTenantOuterPageProps & WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  if (!pageProps.tenantName) throw new Error('pageProps.tenantName is required.');
  const apolloClient = createApolloClient(pageProps.initialApolloState, pageProps.tenantName);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withGlobalGSSP } from '../lib/get-server-side-props';
import { gql, useQuery } from '@apollo/client';

const QUERY = gql`
  query TopPage {
    hello
  }
`;

export default function Home() {
  const { loading, error, data } = useQuery(QUERY);
  return (
    <main>
      <p>top page.</p>
      <div>
        {loading && <p>loading...</p>}
        {error && <p>error: {error.message}</p>}
        {data && <p>data: {JSON.stringify(data)}</p>}
      </div>
    </main>
  );
}

export const getServerSideProps = withGlobalGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  console.log(context.tenantName);
  return { props: {} };
}) satisfies GetServerSideProps;

以下のリポジトリにコード全文があります。

github.com

ポイントをかいつまんで紹介

  • それぞれのページの固有の getServerSideProps (GSSP) のロジックが実行される前後で共通のロジックを呼び出したいことがあるので、High-order Function (HoF) として共通化してます
    • 簡単に言うと関数を引数にとって、関数を返す関数のこと
    • (React Hooks 登場以前の) Redux 触ったことある人なら馴染みあると思う
  • innerGSSP がページの固有の GSSP で、それを HoF に渡すと、「共通ロジック前半部分の実行」=>「innerGSSP の実行」=>「共通ロジック後半部分の実行」みたいな順で実行されていく
    • 「共通ロジック後半部分」で得たデータを innerGSSP に渡したり、innerGSSP の返り値を「共通ロジック後半部分」で拡張してから返したりできる
  • よく使う HoF を合成した withGlobalGSSP という HoF を用意してる
    • export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps; と書いて使う
  • Inner と Outer の型をちゃんと表現してるので、 HoF の適用順序を間違えたら型エラーが出るようになってる
    • HoF の適用順序が正しい時 (右) と間違えた時 (左) の比較
    • withApolloGSSPtenantName を要求するので、先に withTenantGSSP を呼ばないといけない

感想

  • withApolloGSSPwithGlobalGSSP などを使うページコンポーネント側はすごくスッキリする
    • けど HoF 実装側は大変なことになってる!!!
    • 何とかならないものか
  • 厳密に型付けするのを諦めて、もう少しナイーブにやったらメンテナンスしやすい形にできないかな
  • 皆さんはどうしてますか?

YAPC::Kyoto 2023 に参加してきた

登壇とかではなく、いち聴者として参加してきました。

yapcjapan.org

前日祭も参加していて、土日での京都滞在でした。

yapcjapan.connpass.com

僕と YAPC

YAPC への参加は去年の YAPC::Japan::Online 2022 に続いてとなり、YAPC::Kyoto 2023 で2回目です。オフラインの YAPC は初めてでした。

また、別の話として新卒入社のタイミングがコロナと重なっており、参加人数が数百人超える大きなカンファレンスに出たことがほとんどありませんでした (入社前のものを含めると HTML5 Conference 2018 と builderscon tokyo 2019 くらい?)。大きなカンファレンスへの参加が4年ぶりということで楽しみにしてました。

印象深かったセッション

moznion さんの廃墟の話が印象深かったです。出てくる話全部に思い当たる節があるというか、トークの引き込み力がすごかった。

よく面白い発表をなさっている方なのはご存知だったけど、まだ生でその発表を聞くことはできてなかったので、今回直接発表を聞けてよかったです。

docs.google.com

あと mackee さんのデプロイの話も印象深かったです。CGI 時代からどうデプロイの仕方が変遷してきたか、今と昔でどこがどう変わったのか、デモを交えながら丁寧に解説されていてすごく良かった。あと当時の技術的背景の紹介も挟あっていて、なぜその技術がそのタイミングで出てきたのかが納得できる形になっていたのも良かったです。僕のベストトークはこれです。

speakerdeck.com

他にも Hono の 2 セッションも良かったです。個人でカッコいいソフトウェアを作るのはすごく憧れますね。usualoma さんの Hono の内部のルーターの最適化の話が結構面白くて、RegExpRouter がどういうアイデアをもとに作られているかとか、あえて単純な方へと割り切りをしたことで他の最適化が活かせるようになったりとか、なるほどと思いながら聞いてました。

speakerdeck.com

speakerdeck.com

オフラインならではの出来事

セッションを聞いていると dankogai さんが観客席からちょくちょくツッコミを入れられていて、「これがあの Dan The Comment か!」となっていました。YAPC::Japan::Online 2022 のときはリモートゆえにツッコミ不能だったので、すごくオフラインイベントっぽさがありました。あと休憩時間になると廊下に無数にサークルが発生して皆ワイワイ話されていて、あれもオフラインイベント感 MAX でした。

builderscon 以来ぶりの人や、インターネットではよくお見かけするけど物理では面識のない人とかも結構いて、そういう方と挨拶できたのは良かったですね。「ブログいつも読んでます。」とお声がけいただくことも多くて嬉しかったです。

イベントの夜

前日祭があった夜に Hono Conference #1 に参加しました。Hono 使ったことなかったのですが、「使ったことない人もぜひ!」とのことだったので堂々と参加してきました。

初対面の方ばかりだったのですが、ずっと夢中になって話し込んでた!何してたか忘れつつあるけど、Hono contributor 同士の Hono の開発の話を聞いたり、chimame さんと情報共有や Prisma の話をしたりしていた気がする。あと uzulla さんとはオンライン越しに顔を合わせることは何度かあったけど、オフラインで会うのは初で、お会いできてよかった。むちゃくちゃ楽しかったです。

2日目の夜ははてなオフィスに人が集まってて盛り上がってた。結構色々な人とお話した記憶。yusukebe さんに「Hono の tRPC インテグレーション、最近推してますが実際のところどれくらいイケてるものなんですか」と聞いたら Hono でコードを書きながら Hono の RPC モードがどういうものだとか、その裏側はこうなっているとか解説してくださって、お互いにあーだこーだ言っててすごく盛り上がった。あれはよかった。こんなに Hono のこと話してるのに僕が Hono 使ってないの勿体ないので、何とかして普段使いの技術セットにねじ込みたい。

まとめ

面白いセッションもたくさん聞けて、オフラインイベントならではの楽しみも味わえて、めちゃめちゃ楽しかったです! イベントを運営してくださったスタッフの方々、それを支えてくださったスポンサーの方々に感謝です。次回の YAPC もぜひ参加したいです。

あと pastak さんには登壇してくださいとツッコまれてすみませんとなってたので、次回は登壇したい!! まずは Proposal を出すところから...

CPU シミュレータを用いて継続的ベンチマークを安定化させる

id:mizdra は eslint-interactive というツールをメンテナンスしています。このツールを使うと、多数の ESLint エラーを効率的に修正できます (詳しくは以前書いた記事を見てください)。

www.mizdra.net

eslint-interactive では「中規模〜大規模なコードベースであってもキビキビ動く」を大事にしてます。その一環として、eslint-interactive には CI (GitHub Actions) でベンチマークを取り、以前から大きく劣化していたら CI を fail させる仕組みがあります。

しかし CI で実行するためにノイズが大きく、よく誤検知が発生してました。そこで最近 CPU 命令数と CPU キャッシュのヒット率 をメトリクスにしたベンチマークへと移行しました。これにより、約 200% あったノイズが約 3.68% に減少し、安定してベンチマークを取れるようになりました。

非常にニッチな話なのですが、面白いかなと思い紹介してみます。

移行前のベンチマークについて

移行前のベンチマークでは、以下のような Node.js で書かれたスクリプトファイルを実行してベンチマークを取ってました。

github.com

スクリプトは lint 対象となるファイルをファイルシステム上に生成し、それに対して eslint-interactive を実行して lint エラーを修正する作りになってます。この修正に掛かる時間を performance.now で計測しbenchmark-action/github-action-benchmark というベンチマーク結果を視覚化する Actions に渡しています。

実際に視覚化されたベンチマーク結果は以下の URL から確認できます ([OBSOLETE BENCHMARK] というラベルが付いたものが、移行前のベンチマーク結果です)。

視覚化されたベンチマーク結果。commit ごとのメトリクスの変化が確認できる。

見ての通りメトリクスが波打っているのですが、ここでパフォーマンスの改善や劣化が起きている訳ではなく、ほとんど全てがノイズによるものです。いくつか前提条件の異なる数種類のベンチマーク結果があるのですが、その中には 200% のノイズ (最大値 / 最小値 * 100 - 100 から算出) を含むものもあります。

200% のノイズを含むベンチマーク結果。

eslint-interactive では 150% メトリクスが悪化した際に CI を fail させるようにしていたのですが、このノイズのせいで contributor から貰った PR で誤検知が発生して contributor を混乱させてしまったりと、よくトラブルを引き起こしてました。

ノイズが発生する理由とその大きさ

一般に、クラウド管理された CI サービスでは、物理マシンの上に仮想マシンが構築されていて、その仮想マシンの上で job を実行します。物理的なマシン 1 台を複数のユーザ・複数の job で共有するので、非常に大きなノイズが発生します。

実際に、Travis-CI で発生するノイズを調査した記事があり、これによると 50% のノイズが発生することが一般的だとされています (しかも 10,000% のノイズを持つ 4 つの外れ値を除外した上で...)。

bheisler.github.io

Note that there were four benchmark results in the cloud set with percentage differences greater than 10,000% which I’ve removed as outliers. Those were not included in the calculations above; if they were included the cloud numbers would be substantially worse. I opted to remove them after inspecting them and finding inconsistencies in those benchmark results which lead me to suspect that the logs were damaged. For example, one benchmark shows the time for each iteration increased by more than 200x but the throughput for the same benchmark appears to have increased slightly, rather than decreased as one would expect.

... many of the comparisons show shifts of +-2%, roughly similar to the noise observed in local benchmarks. However, differences of as much as 50% are fairly common with no change in the code at all, which makes it very difficult to know if a change in benchmarking results is due to a change in the true performance of the code being benchmarked, or if it is simply noise. Hence, unreliable.

あくまで Travis-CI の事例ですが、「ベンチマークの信頼性を損なう程度のノイズがある」ことはどの CI サービスにも言えることかと思います。

パッと思いつく解決策

パッと思いつく解決策は「ベンチマークの試行回数を増やし、その中央値をメトリクスとする」手法です。例えば、ベンチマークの job で10回ベンチマークスクリプトを回し、その中央値をメトリクスとします。しかし、その job が実行される仮想マシンの CPU がたまたまサーマルスロットリングを起こしていた場合、10回の計測値がすべて劣化して中央値もそれに引きづられてしまいます。複数の job に分けて計測してその影響を受けにくくすることもできますが、手間ですし、依然として一定のノイズがあります。

次に思いつく解決策は、自前の CI runners を用意して、そこでベンチマークを実行することです。GitHub Actions でいうところの self-hosted runners です。この手法なら物理的なマシンを専有できるので、かなりノイズを排除できますが、それを用意するための労力や維持費の問題が出てきます。

どちらも eslint-interactive には合わなさそうだったので、他の手段を検討することにしました。

CPU 命令数と CPU キャッシュのヒット率をメトリクスとする手法

なにか良い方法はないかと途方に暮れていたところ、まさに求めていたものはこれだ!という記事を見つけました。

pythonspeed.com

詳しい内容は記事を読んでもらえれば良いですが、いくつか要点を書くと...

  • CPU 命令数が増えたり、CPU キャッシュヒット率が低下すると、一般にパフォーマンスは劣化する
    • CPU 命令数と CPU キャッシュヒット率を見れば、パフォーマンスの変化がある程度わかる
    • 実行時間はブレやすい一方、実行された CPU 命令の数はブレにくい
    • よって壁時計時間や CPU 時間よりも一貫性のあるメトリクスが得られる
  • 「Valgrind」という仮想環境と、「Cachegrind」というCPU キャッシュプロファイラを用いる
    • この仮想環境上でベンチマーク対象のプログラムを実行し、CPU 命令数とキャッシュヒット率を計測する
    • キャッシュというのは L1 キャッシュや L2 キャッシュなどのこと
  • CPU キャッシュのコストがうまく反映されるよう、各層ごとのキャッシュヒット率を重み付けした 1 つの値で比較する
    • ハードウェアによって多少の重みが異なるが、(L1 + D1 ヒット数) + 5 × (LL ヒット数) + 35 × (RAM ヒット数) で十分
    • メトリクスが1つに結合されるので、比較もしやすい

実際に記事の中では、Python で書かれたプログラムを対象にこの手法を適用したところ、0.000001% 以内のノイズに収まったと紹介しています。

なんと sqlite でも長年同様の方法でベンチマークを取っているそうです。

Valgrind/Cachegrind を使った手法を試すには

Valgrind/Cachegrind を使ってCPU 命令数とキャッシュヒット率を計測し、キャッシュコストを考慮した単一のメトリクスを計算する...ところまで自動でやってくるスクリプトを、件の記事の筆者が作っています。手軽に試したいならこれを使うと良いと思います。より一貫性のある結果を得るために ASLR (アドレス空間配置のランダム化) を無効化する機能も入ってます。

github.com

Valgrind/Cachegrind を使った手法のデメリット

もちろん銀の弾丸ではないので、デメリットや注意点があります。

  • 他のソフトウェアとベンチマークメトリクスを比較する用途には使えない
    • CPU 時間を計測している訳ではないので
    • 「1つのソフトウェアが以前と比較して改善したか・劣化したか」の比較のみに使える
  • 遅い
    • 仮想環境で実行するので
    • ただしノイズを排除するために複数回実行する手間はなくなる
  • 実際にユーザが利用する CPU をエミュレート出来るわけではない
  • 命令ごとの実行コストの違いが反映されていない
    • 命令の種類によってコスト異なるはずだが、それが無視されてる
  • CPU 以外のハードウェアのシミュレートがされない
    • 例えばファイルシステムへの I/O はシミュレーターレートされない
    • そういったものがが支配的なプログラムでは、ノイズが大きくなるはず
  • 最新世代の CPU で実装されているような最適化がシミュレートされない
    • 少し古い世代の CPU の最適化がシミュレートされるようになってるため
    • 現実のパフォーマンスと多少ギャップがある
  • 他にも色々...

試してみた

eslint-interactive では、以前からパフォーマンスが改善したか劣化したかがわかれば良いのでマッチしそうな感じがします。そこで、実際にこの手法を試してみることにしました。

github.com

その結果、ノイズが 200% => 3.68% と大幅に減少しました :tada:

ベンチマーク結果のグラフにも、はっきりとその成果が現れています。

新しいベンチマークのグラフ。大きなノイズは見られない。

実際にパフォーマンスを劣化させる変更を仕込んで、CI が fail することも確認できました。きちんとパフォーマンスの劣化を検知できているようです。

今の所大きな外れ値も出ておらず、非常に安定しているように見えています。

試してみて気になったこと

試した上でいくつか気になったことも書いてみます。

  • 実行が遅い!
    • 直接実行すると 3 秒 (ref)、Valgrind/Cachegrind を経由すると 2分46秒 (ref)
    • 仮想環境で実行しているためと思われる...が遅すぎる気もする
  • ランダム性を排除するために、いくつかオプションを渡す必要がある
  • ノイズが思っていたよりも大きい
    • 記事の Python プログラムの事例では 10 回の試行で 0.000001% 以内に収まってる
    • しかし eslint-interactive では 30 回の試行で 3.68% のノイズがあった
    • node コマンドに渡すオプションが不十分で、ランダム性が十分に排除しきれてない?
      • V8 や Node.js に詳しい人が見たら何かわかるかも
      • 誰かわかりませんか!
    • eslint-interactive ではファイルシステムへの I/O が支配的なため?
      • インメモリな仮想的なファイルシステムへ書き込むようにしたら良い?
      • 試しにやってみた
      • 確かにファイルシステムへの I/O が原因っぽい
      • しかしインメモリなファイルシステムに fixture を用意する都合上、fixture を作成するコードも Valgrind/Cachegrind 上で実行する羽目になってる
        • CPU 命令数の総量が増える (21 billion instructions => 27 billion instructions) のでその分ノイズの変化量も小さくなる、というバイアスがある点には注意
        • 加えてベンチマークの実行時間が 3分 => 4分に増えた
      • ちょっと導入するか悩ましい

まとめ

  • クラウド管理の CI では、ベンチマークの信頼性を損なうのに十分なノイズがある
  • そうした環境でも安定的にベンチマークを取るには...
    • CPU 命令数とCPU キャッシュヒット率を見れば良い
      • Valgrind/Cachegrind を使う
    • ランタイムのランダム性を排除する
      • ASLR の無効化、seed の固定化など
  • Valgrind/Cachegrind のシミュレートは完璧ではない
    • 現実のパフォーマンスと多少のギャップがある
    • シミュレートされないリソースへのアクセスはノイズの原因となる

思ったよりもノイズを小さくできなかったですが、パフォーマンスの劣化を検知する用途としては十分そうです。これで不安定なベンチマークともおさらばできそうです。

合わせて読みたい


  • 2022/2/13追記
    • 「シミュレーター」が「シュミレーター」になっていたの直しました (全く気づかなかった…) (ブコメありがとうございます)

試行錯誤を邪魔しない開発環境

  • ある機能を実装する際、完成形のコードになるまでには、プログラムとして不正確な状態や、プロダクト品質ではない状態を経る
    • 静的型検査や lint rule に違反したコードが途中に挟まる
  • 型エラーや lint エラーは望ましくないので、できるだけ早くこうした情報を開発者に伝え、気付けるようにすると良い
    • CI でこうしたエラーを検知して、Pull Request をマージする前に気づけるようにするとか
    • エディタ上にエラーの情報を表示して、コーディング中に気づけるようにするとか
  • エラーを積極的に通知してくれるのはありがたいけど、やりすぎには注意するべき

なんとなくでも動いてくれたほうが嬉しい

  • 例えば lint エラーがあった際に、watch モードで起動しているビルドやテストの実行を止めて、lint エラー見つけたよーと教えてくれる開発環境がたまにあるけど...
    • 別にビルドやテストの実行は止める必要ないと思う
    • なんとなくでも動いて結果を教えてくれるほうが、試行錯誤しやすくて嬉しい
  • TypeScript なら(静的)型エラーを無視してトランスパイル...なんてこともできる
    • ts-loader に transpileOnly: true オプションを渡したり、swc のような (型検査なしで) トランスパイルだけするツールを使えばできる
      • 多少型のミスマッチがあっても実行できるところまでは動くし、問題があったら実行時エラー (TypeError など) が出るだけ
    • このアプローチはここ数年で当たり前になってきた気がする
      • 試行錯誤のしやすさのため...というよりは watch ビルドに掛かる時間を短縮するためというのが主目的
      • あと swc や esbuild のようなツールが普及したというのもありそう
    • 何だかんだで背後が動的型検査の JavaScript だからこそできる荒業だと思う

create-react-app のおせっかい機能

  • 「create-react-app」という React アプリケーションを作成するためのフレームワークがある
  • 主に React 初学者が React を使った開発方法を学ぶためにある
    • しかし面倒な環境構築が不要かつ、難しいビルド周りの設定を上手く隠蔽してくれるので、業務でも十分有効なフレームワークだと思う
    • id:mizdra も業務で使ってる
  • ただ、watch ビルド中に lint エラー or (静的)型エラーが検出された時の挙動が id:mizdra が期待しているものからズレててイマイチ
    • 以下のように lint エラーをブラウザの画面いっぱいにオーバーレイで表示してくる
    • ブラウザで開いているページ上にオーバーレイで lint エラーが表示されている様子
    • 一応ビルド自体はされていて、右上のバツボタンからオーバーレイを閉じればページを操作できる
      • とはいえビルドされ直されるたびにオーバーレイが出るのでわずらわしい
    • おそらくReact 初学者がメインユーザーなフレームワークゆえに、エラーを目立たせる作りになっているのだろう
      • しかし初学者でない人からすると、おせっかい機能だと思う
  • そういう感じなので、最近は TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true react-scripts start で起動してる

更に一歩踏み込んで考えてみる

  • 空の body を持つ関数を禁止する lint rule とかあるけど、ああいうのもわずらわしく感じる
    • () => {} から書き始めるので、大体空になって怒られる
    • 書いている途中で赤線が引かれて、「あれ何か間違った?」とびっくりすることが多い
    • こういうのも警告レベルを落としたり、無効にすると良い?
      • しかしキリがなくて大変そう
      • こういう細かいチューニングをするよりは他のことに時間を掛けたい
    • 発想は面白いと思う!
  • エディタ上で未 format のコードに赤線を引いてくる開発環境もあるけど、あれもわずらわしい
    • インデントの深さが間違ってたら真っ赤になる
      • スタイルガイドに合ってないだけで、コードの挙動は正しいのに…
    • エディタ上では警告せずに formatOnSave で勝手に format する & CI 上では警告する、くらいが丁度よい

書いてみて思ったけど、「静的型エラーを無視してトランスパイルする」とか「ブラウザにエラー内容をオーバーレイで表示しない」とか、Web フロントエンドならではの話題ばかりだった。

ネイティブモジュールに依存しない node-canvas 代替ライブラリを使う

Web フロントエンドにて、Canvas を使った View のテストを書きたいことがたまにあります。ブラウザであれば以下のようにして Canvas を利用できますが、テストが実行される Node.js ではそのような API は生えていません。

const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

そこで Node.js では、node-canvas という npm パッケージがよく使われます。これを使うと、Web Canvas API 互換な API を用いて、Node.js でも Canvas を利用できます。

import { createCanvas } from 'canvas';

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

余談ですが、Node.js に DOM API の互換実装を提供するライブラリ「jsdom」では、HTMLCanvasElementHTMLImageElement の実装に、この node-canvas を利用しています。

ネイティブモジュールの依存問題

そんな node-canvas ですが、実はネイティブモジュールに依存しています。そのため、いくつかのモジュールがマシンにインストールされていなければ実行できません。

一度マシンにインストールしてしまえば良いものではありますが、複数人で開発するようなプロジェクトで node-canvas に依存していると、新しくチームメンバーがやってくる度にインストール方法を案内する必要があります。加えて、CI 上でテストを走らせる際も CI 環境にインストールが必要になってきます。

ちょっとした手間ではあるのですが、面倒なのでなんとかして解消できると嬉しいです。

@napi-rs/canvas を使う

@napi-rs/canvas という、同じく Web Canvas API 互換な API を実装したライブラリがあります。このパッケージはネイティブモジュールに依存しない作りになっていて、ネイティブモジュールをインストールせずとも node-canvas と同等のことができます。

$ npm i -D @napi-rs/canvas
import { createCanvas } from '@napi-rs/canvas';
//                            ^^^^^^^^^^^^^^^
//                            ここが canvas から変わっただけ

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

jsdom と組み合わせるにはもう一手間必要

先程ちょっと触れましたが、jsdom からは const Canvas = require("canvas"); で node-canvas が参照されています。従って、jsdom の HTMLCanvasElement を呼び出すと node-canvas が使われてしまう…ということになってしまいます。

そこで id:mizdra@napi-rs/canvas を以下のようなコマンドでインストールしています。

$ npm i -D canvas@npm:@napi-rs/canvas

このコマンドを実行すると、package.json 上では以下のような依存関係の情報が書き込まれます。

{
  // ...
  "devDependencies": {
    // ...
    "canvas": "npm:@napi-rs/canvas@^0.1.34",
  }
}

これで require('canvas') というコードが require('@napi-rs/canvas') へと解決され、node-canvas の代わりに @napi-rs/canvas が利用されるようになります。jsdom の HTMLCanvasElement からも @napi-rs/canvas が使われるようになり、ネイティブモジュール無しでテストを実行できるようになります。

こことかここを見るとわかるのですが、実際には @napi-rs/canvasnode-canvas の API を完全に模倣している訳ではないので、もしかしたらうまく動かないこともあるかもしれません… とりあえず手元の HTMLCanvasElement を使ったテストケースは正常に動作していますが、凝ったテストを書いてると動かないとかあるかもです。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.