Web Totals

Học từ Facebook #91: Profiler kiểu middleware và memo có canary

RelayProfiler xâu handler thành chuỗi middleware quanh hàm cần đo. Ads Manager memo selector "thuần" và chạy lại ngẫu nhiên 0,1% số lần để kiểm chứng.

hunghg255hunghg25529 min read
#facebook#relay#profiling#memoization#observability
Học từ Facebook #91: Profiler kiểu middleware và memo có canary

Hai câu hỏi hay gặp khi làm hiệu năng ở quy mô lớn. Thứ nhất: làm sao đo một hàm trong thư viện (ví dụ store.lookup của Relay) mà không sửa thư viện, và cho phép nhiều công cụ cùng đo một lúc? Thứ hai: memo một hàm dựa trên giả định "hàm này thuần" thì nhanh, nhưng nếu giả định sai thì UI hiện dữ liệu cũ. Làm sao biết giả định còn đúng, khi hàng nghìn selector được nhiều đội viết?

Trong bundle của Facebook có hai module trả lời hai câu đó: RelayProfiler (phần cuối của loạt bài về Relay) và adsMemoizeSelectorGetStores của Ads Manager.

Tip

Tóm tắt

  • RelayProfiler.instrument(name, fn) trả về hàm bọc. Mỗi lần gọi, các handler (name, next) được xâu thành chuỗi: handler chung "*" ngoài cùng, rồi handler theo tên, rồi handler gắn riêng, cuối cùng là hàm gốc. Không ai gắn handler thì hàm bọc gọi thẳng hàm gốc, gần như không tốn gì.
  • Bản gốc dùng một ngăn xếp frame và các bộ đếm thay cho closure. Ba lỗi đã kiểm bằng node: handler gọi next() hai lần thì hàm gốc chạy hai lần, hàm gốc ném lỗi thì frame (giữ this và đối số) không bao giờ được gỡ, và handler quên next() chỉ bị phát hiện sau khi mọi handler khác đã chạy xong.
  • adsMemoizeSelectorGetStores memo selector được khai báo là thuần, rồi với xác suất CANARY_SAMPLE_RATE = 0.001 vẫn chạy lại hàm gốc và so nông với kết quả memo. Khác nhau thì log lỗi "mustfix". Selector không thuần thì không memo, chỉ đếm số lần gọi và gửi báo cáo mỗi 5 phút.
  • Quy tắc mang về: giả định về hiệu năng (thuần, bất biến, không trùng) nên được kiểm chứng bằng lấy mẫu trên production, vì test không bao phủ hết dữ liệu thật.

#Facebook làm thế nào?

#RelayProfiler: ba nhóm handler, một ngăn xếp

relay-runtime/util/RelayProfiler (trong 99 ChatComposer/all.js và 31.js, giống hệt). Relay là mã nguồn mở (MIT). Hàm instrument, đổi tên biến:

instrument(name, fn) {
  if (!shouldInstrument(name)) {           // tên bắt đầu bằng "@": không bọc
    fn.attachHandler = noop; fn.detachHandler = noop;
    return fn;
  }
  aggregateHandlers[name] ??= [];
  const all = aggregateHandlers['*'], named = aggregateHandlers[name], local = [], stack = [];
  const next = function next() {
    const f = stack[stack.length - 1];
    if (f[0]) { f[0]--; all[f[0]](name, next); }
    else if (f[1]) { f[1]--; named[f[1]](name, next); }
    else if (f[2]) { f[2]--; local[f[2]](name, next); }
    else f[5] = fn.apply(f[3], f[4]);
  };
  const instrumented = function () {
    if (named.length === 0 && local.length === 0 && all.length === 0) return fn.apply(this, arguments);
    stack.push([all.length, named.length, local.length, this, arguments, NOT_INVOKED]);
    next();
    const f = stack.pop();
    if (f[5] === NOT_INVOKED) throw new Error('RelayProfiler: Handler did not invoke original function.');
    return f[5];
  };
  instrumented.attachHandler = (h) => local.push(h);
  instrumented.detachHandler = (h) => removeFromArray(local, h);
  instrumented.displayName = '(instrumented ' + name + ')';
  return instrumented;
}
js

Mỗi lần gọi đẩy một frame [số handler "*", số handler theo tên, số handler riêng, this, arguments, kết quả]. Hàm next dùng chung cho mọi handler: nó đọc frame trên đỉnh, giảm bộ đếm của nhóm ngoài cùng còn handler, và gọi handler đó. Khi cả ba bộ đếm về 0, nó gọi hàm gốc và ghi kết quả vào ô cuối frame.

Cách này không tạo closure nào mỗi lần gọi: một next duy nhất, một mảng frame. Ngăn xếp (thay vì một biến) cho phép hàm được instrument gọi lồng nhau hoặc đệ quy: lời gọi trong đẩy frame mới, xong thì pop, frame ngoài vẫn nằm đó.

Hệ quả về thứ tự: vì bộ đếm giảm từ cuối mảng, handler gắn sau cùng chạy ngoài cùng trong mỗi nhóm. Nhóm "*" luôn bọc ngoài nhóm theo tên, nhóm theo tên bọc ngoài nhóm riêng.

Trong bundle, RelayModernStore gọi RelayProfiler.instrumentMethods(prototype, { lookup: 'RelayModernStore.prototype.lookup' }) và RelayResponseNormalizer.normalize cũng được instrument. Bất kỳ công cụ nào (DevTools nội bộ, logger) có thể attachAggregateHandler('RelayModernStore.prototype.lookup', …) để đo mà không đụng vào Relay.

#profile(): đo một khoảng thời gian

Phần thứ hai của module là profile(name, state): không bọc hàm, chỉ gọi các handler "bắt đầu", mỗi handler trả về một hàm "kết thúc":

profile(name, state) {
  const handlers = /* profileHandlers[name] nối với profileHandlers['*'] */;
  const stops = [];
  for (let i = handlers.length - 1; i >= 0; i--) stops.unshift(handlers[i](name, state));
  return { stop(error) { stops.forEach((s) => s(error)); } };
}
js

Handler bắt đầu theo thứ tự ngược, hàm kết thúc chạy theo thứ tự xuôi: handler bắt đầu sau cùng thì dừng trước tiên, đúng kiểu lồng nhau. Không có handler nào thì profile trả về một object {stop: noop} dùng chung, không cấp phát.

Ai dùng? createRelayFBNetworkFetch gọi RelayProfiler.profile('fetchRelayQuery', params) khi request bắt đầu, và stop() khi có payload hoặc lỗi. CometRelayQueryProfiler gắn một profile handler: lúc bắt đầu thì thêm một marker Relay_<tên query>_start vào interaction trace đang chạy, lúc kết thúc thì gỡ marker và thêm một subspan Relay_<tên query> kèm usedCache, usedPrefetcher. Mỗi lần mở trang, trace của Facebook biết query nào chạy bao lâu, có dùng cache không.

Đọc code createRelayFBNetworkFetch thấy stop() nằm trong callback next của Observable. Query có @defer hoặc @stream trả nhiều payload, nên stop() có lẽ bị gọi nhiều lần và trace nhận nhiều subspan cho một query. Ngược lại, request bị huỷ (unsubscribe trước payload đầu) thì stop() không bao giờ được gọi và marker nằm lại trong trace.

#Ba lỗi trong instrument

Mình nạp module vào node (giả lập __d) và chạy từng ca:

  1. Handler gọi next() hai lần thì hàm gốc chạy hai lần. Ví dụ một handler "thử lại khi lỗi" viết sai. Lần gọi next() thứ hai thấy cả ba bộ đếm đã về 0 và gọi thẳng fn.apply. Kết quả trong node: thứ tự agg> named> local> fn <local fn <named <agg, hàm gốc chạy hai lần, không có cảnh báo nào. Nếu hàm gốc là commitPayload, dữ liệu được ghi hai lần.
  2. Hàm gốc ném lỗi thì frame kẹt lại. stack.pop() không nằm trong finally. Lỗi bay ra ngoài, frame (giữ this và arguments) nằm trên stack mãi mãi. Kiểm bằng node với --expose-gc: truyền một object lớn vào hàm được instrument và cho hàm ném lỗi, sau khi GC thì WeakRef vẫn trỏ được tới object. Cùng thử nghiệm nhưng hàm không ném lỗi thì object được thu hồi. Các lần gọi sau vẫn chạy đúng vì chỉ đọc frame đỉnh, nên đây là rò bộ nhớ chậm chứ không phải lỗi logic.
  3. Handler quên next() bị phát hiện muộn. Lỗi chỉ được ném sau khi next() của handler ngoài cùng trả về. Mọi handler bên ngoài đã chạy hết phần "sau" (ghi log "xong", đo thời gian), trong khi hàm gốc chưa hề chạy. Thông báo lỗi cũng không nói handler nào quên.

Nhắc lại: chỉ khi có handler được gắn thì mới đi vào nhánh có stack, nên trên production (thường không gắn gì) cả ba lỗi đều không xảy ra. Chúng cắn công cụ đo, đúng lúc bạn đang cố tìm lỗi.

#adsMemoizeSelectorGetStores: memo rồi kiểm chứng ngẫu nhiên

Ads Manager có hàng nghìn selector kiểu Flux. Mỗi selector khai báo getStores(props): danh sách store nó phụ thuộc, để biết khi nào cần tính lại. getStores được gọi rất nhiều lần, và nếu nó thuần (cùng props thì cùng danh sách store) thì có thể memo. Module adsMemoizeSelectorGetStores (trong 106.js) quyết định:

function adsMemoizeSelectorGetStores(isGetStoresPure, getStores, name) {
  const memoOn = gkx('1709087'), canaryOn = gkx('1724844'), countOn = gkx('1727621');
  if (isGetStoresPure && memoOn && canaryOn) return memoizeWithCanary(getStores, name);
  if (isGetStoresPure && memoOn) return memoizeByIdentity(getStores);
  return !isGetStoresPure && countOn ? countCalls(getStores, name) : getStores;
}

function memoizeWithCanary(getStores, name) {
  const memoized = memoizeByIdentity(getStores);
  return function (props) {
    const result = memoized.call(this, props);
    if (Math.random() <= AdsPureGetStoresConfig.CANARY_SAMPLE_RATE) {
      const fresh = getStores.call(this, props);
      if (!shallowEqual(result, fresh))
        FBLogger('ads_selectors_pure_get_stores').mustfix(
          'Selector "%s" getStores was determined to be pure, but is not! …', name);
    }
    return result;
  };
}
js

CANARY_SAMPLE_RATE nằm trong cấu hình server gửi xuống (000.siteData): 0.001. Cứ 1.000 lần gọi thì một lần selector chạy hai lần, và nếu kết quả khác, đội sở hữu nhận lỗi "mustfix". Chi phí thêm 0,1%, đổi lại có bằng chứng từ dữ liệu thật rằng giả định "thuần" vẫn đúng.

Selector không thuần thì không memo, nhưng được đếm:

function countCalls(getStores, name) {
  return function (props) {
    counts.set(name, (counts.get(name) ?? 0) + 1);
    return getStores.call(this, props);
  };
}
// cứ 5 phút và khi beforeunload: gửi các selector có count > 1 cùng thời lượng lên QPL, rồi counts.clear()
js

Báo cáo này cho biết selector nào không thuần mà bị gọi nhiều nhất, tức là đáng làm cho thuần nhất. Cả ba nhánh đều sau gatekeeper, nên có thể bật tắt từng phần cho từng nhóm người dùng.

Hai điểm đọc code thấy:

  • memoizeByIdentity dùng Map mạnh, không giới hạn: function (c) { b.has(c) || b.set(c, a.call(this, c)); return b.get(c) }. Nếu props là object mới mỗi lần render (thường gặp), Map giữ mọi object props từng thấy cho tới khi trang đóng, và memo không bao giờ trúng ở lần render sau. Demo ở tab thứ hai đếm kích thước Map này.
  • this bị chụp lúc tạo: memoizeWithCanary lưu const d = this khi được gọi để tạo selector, rồi dùng d cho mọi lần gọi sau. Với cách module được dùng (this không quan trọng) thì không sao, nhưng nó khác với cái tên gợi ý.

#Bên trong: thứ tự trong chuỗi handler

flowchart LR
  C["gọi lookup(sel)"] --> A["handler '*': đo thời gian"]
  A -->|"next()"| N["handler theo tên: log"]
  N -->|"next()"| L["handler riêng: đếm"]
  L -->|"next()"| F["lookup gốc"]
  F -->|"kết quả"| L
  L -->|"trả về"| N
  N -->|"trả về"| A
  A --> R["kết quả về người gọi"]

Đây là mô hình middleware quen thuộc của Express hay Koa, nhưng đồng bộ và không truyền đối số: handler chỉ quyết định chạy trước và sau hàm gốc, không sửa đối số, không sửa kết quả. Muốn đo thời gian hay đếm số lần gọi thì như vậy là đủ, và handler không thể vô tình làm thay đổi hành vi của Relay. Trừ khi gọi next() hai lần.

#Viết lại

File src/lib/profilerChain.ts. Phần instrument, chuỗi handler được chụp thành một mảng lúc gọi, mỗi bước có cờ called riêng:

src/lib/profilerChain.ts
  function instrument<F extends (...args: any[]) => any>(name: string, fn: F): Instrumented<F> {
    if (!enabled(name)) {
      return Object.assign(fn, { attachHandler() {}, detachHandler() {}, displayName: name });
    }
    if (!aggregate[name]) aggregate[name] = [];
    const local: Handler[] = [];

    const wrapper = function (this: unknown, ...args: unknown[]) {
      // chụp danh sách lúc gọi; handler gắn sau cùng chạy ngoài cùng trong mỗi nhóm (giống bản gốc)
      const chain = [...aggregate['*']]
        .reverse()
        .concat([...aggregate[name]].reverse(), [...local].reverse());
      if (chain.length === 0) return fn.apply(this, args);
      let result: unknown;
      let ran = false;
      const step = (i: number) => {
        if (i === chain.length) {
          ran = true;
          result = fn.apply(this, args);
          return;
        }
        let called = false;
        chain[i](name, () => {
          if (called) throw new Error(`Profiler: handler #${i} của "${name}" gọi next() hai lần`);
          called = true;
          step(i + 1);
        });
        if (!called) throw new Error(`Profiler: handler #${i} của "${name}" không gọi next()`);
      };
      step(0); // lỗi từ fn hay từ handler đều đi thẳng ra ngoài, không để lại trạng thái
      if (!ran) throw new Error('Profiler: hàm gốc không được gọi');
      return result;
    } as unknown as Instrumented<F>;

    wrapper.attachHandler = (h) => void local.push(h);
    wrapper.detachHandler = (h) => remove(local, h);
    wrapper.displayName = `(instrumented ${name})`;
    return wrapper;
  }
ts

Memo selector có canary. Object đi vào WeakMap, giá trị nguyên thuỷ vào một Map giới hạn 100 mục:

src/lib/profilerChain.ts
export function memoizeSelector<A, R>(fn: (arg: A) => R, opts: MemoOptions) {
  const { name, isPure, sampleRate, random = Math.random, onImpure, callCounter } = opts;
  const stats: SelectorStats = { calls: 0, computed: 0, canaryChecks: 0, impureDetected: 0 };
  const weak = new WeakMap<object, R>();
  const strong = new Map<unknown, R>();
  let reported = false;

  const compute = (arg: A) => {
    stats.computed++;
    return fn(arg);
  };
  const selector = (arg: A): R => {
    stats.calls++;
    if (!isPure) {
      callCounter?.set(name, (callCounter.get(name) ?? 0) + 1);
      return compute(arg);
    }
    const isObj = (typeof arg === 'object' && arg !== null) || typeof arg === 'function';
    const cache = isObj ? weak : strong;
    const key = arg as unknown as object;
    if (!cache.has(key)) {
      if (!isObj && strong.size >= 100) strong.delete(strong.keys().next().value);
      cache.set(key, compute(arg));
      return cache.get(key)!;
    }
    const memo = cache.get(key)!;
    if (sampleRate > 0 && random() < sampleRate) {
      stats.canaryChecks++;
      if (!shallowEqual(memo, compute(arg))) {
        stats.impureDetected++;
        if (!reported) {
          reported = true; // báo một lần cho mỗi selector, tránh spam log
          onImpure?.(name);
        }
      }
    }
    return memo;
  };
  return Object.assign(selector, { stats });
}
ts
Xem toàn bộ src/lib/profilerChain.ts
src/lib/profilerChain.ts
/**
 * Profiler kiểu middleware, ý tưởng từ RelayProfiler (viết lại):
 * - instrument(name, fn) trả về wrapper; mỗi lần gọi, các handler được xâu thành chuỗi
 *   "*" (aggregate chung) → handler theo tên → handler gắn riêng cho wrapper, cuối cùng mới tới fn gốc,
 * - mỗi handler nhận (name, next) và phải gọi next() đúng một lần,
 * - tên bắt đầu bằng "@" thì không instrument (trả lại fn gốc).
 * Khác bản gốc: chặn next() gọi hai lần, dọn frame khi fn ném lỗi, chụp danh sách handler lúc gọi.
 */

export type Handler = (name: string, next: () => void) => void;
export type ProfileHandler = (name: string, state?: unknown) => ((error?: unknown) => void) | void;

export type Instrumented<F extends (...args: any[]) => any> = F & {
  attachHandler(h: Handler): void;
  detachHandler(h: Handler): void;
  displayName: string;
};

function remove<T>(list: T[], item: T) {
  const i = list.indexOf(item);
  if (i !== -1) list.splice(i, 1);
}

export function createProfiler() {
  const aggregate: Record<string, Handler[]> = { '*': [] };
  const profileHandlers: Record<string, ProfileHandler[]> = { '*': [] };
  const enabled = (name: string) => name.charAt(0) !== '@';

  function instrument<F extends (...args: any[]) => any>(name: string, fn: F): Instrumented<F> {
    if (!enabled(name)) {
      return Object.assign(fn, { attachHandler() {}, detachHandler() {}, displayName: name });
    }
    if (!aggregate[name]) aggregate[name] = [];
    const local: Handler[] = [];

    const wrapper = function (this: unknown, ...args: unknown[]) {
      // chụp danh sách lúc gọi; handler gắn sau cùng chạy ngoài cùng trong mỗi nhóm (giống bản gốc)
      const chain = [...aggregate['*']]
        .reverse()
        .concat([...aggregate[name]].reverse(), [...local].reverse());
      if (chain.length === 0) return fn.apply(this, args);
      let result: unknown;
      let ran = false;
      const step = (i: number) => {
        if (i === chain.length) {
          ran = true;
          result = fn.apply(this, args);
          return;
        }
        let called = false;
        chain[i](name, () => {
          if (called) throw new Error(`Profiler: handler #${i} của "${name}" gọi next() hai lần`);
          called = true;
          step(i + 1);
        });
        if (!called) throw new Error(`Profiler: handler #${i} của "${name}" không gọi next()`);
      };
      step(0); // lỗi từ fn hay từ handler đều đi thẳng ra ngoài, không để lại trạng thái
      if (!ran) throw new Error('Profiler: hàm gốc không được gọi');
      return result;
    } as unknown as Instrumented<F>;

    wrapper.attachHandler = (h) => void local.push(h);
    wrapper.detachHandler = (h) => remove(local, h);
    wrapper.displayName = `(instrumented ${name})`;
    return wrapper;
  }

  return {
    instrument,
    /** Bọc mọi method trong `names` của object. */
    instrumentMethods<T extends Record<string, any>>(obj: T, names: Record<string, string>) {
      for (const key of Object.keys(names)) {
        if (typeof obj[key] === 'function')
          (obj as Record<string, unknown>)[key] = instrument(names[key], obj[key]);
      }
    },
    attachAggregateHandler(name: string, h: Handler) {
      if (!enabled(name)) return;
      (aggregate[name] ??= []).push(h);
    },
    detachAggregateHandler(name: string, h: Handler) {
      if (aggregate[name]) remove(aggregate[name], h);
    },
    /** Đo một khoảng không gắn với hàm: start ngay, trả về stop(). Handler bắt đầu sau thì dừng trước. */
    profile(name: string, state?: unknown) {
      const list = [...(profileHandlers[name] ?? []), ...profileHandlers['*']];
      const stops: ((error?: unknown) => void)[] = [];
      for (let i = list.length - 1; i >= 0; i--) {
        const stop = list[i](name, state);
        if (typeof stop === 'function') stops.unshift(stop);
      }
      return { stop: (error?: unknown) => stops.forEach((s) => s(error)) };
    },
    attachProfileHandler(name: string, h: ProfileHandler) {
      if (!enabled(name)) return;
      (profileHandlers[name] ??= []).push(h);
    },
    detachProfileHandler(name: string, h: ProfileHandler) {
      if (profileHandlers[name]) remove(profileHandlers[name], h);
    },
  };
}

/* ------------------------------------------------------------------ */
/* Memo selector có canary lấy mẫu (ý tưởng từ adsMemoizeSelectorGetStores) */

export function shallowEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  return ka.every(
    (k) => Object.prototype.hasOwnProperty.call(b, k) && Object.is((a as any)[k], (b as any)[k]),
  );
}

export interface SelectorStats {
  calls: number;
  computed: number;
  canaryChecks: number;
  impureDetected: number;
}

export interface MemoOptions {
  name: string;
  /** người viết khẳng định hàm thuần (cùng đối số → cùng kết quả) */
  isPure: boolean;
  /** xác suất chạy lại hàm gốc để kiểm chứng, vd 0.001 */
  sampleRate: number;
  random?: () => number;
  onImpure?: (name: string) => void;
  /** đếm số lần gọi của selector không thuần, để báo cáo định kỳ */
  callCounter?: Map<string, number>;
}

/**
 * Selector thuần: memo theo identity của đối số (WeakMap cho object, nên không giữ object chết),
 * và thỉnh thoảng chạy lại hàm gốc để kiểm chứng. Selector không thuần: không memo, chỉ đếm lời gọi.
 */
export function memoizeSelector<A, R>(fn: (arg: A) => R, opts: MemoOptions) {
  const { name, isPure, sampleRate, random = Math.random, onImpure, callCounter } = opts;
  const stats: SelectorStats = { calls: 0, computed: 0, canaryChecks: 0, impureDetected: 0 };
  const weak = new WeakMap<object, R>();
  const strong = new Map<unknown, R>();
  let reported = false;

  const compute = (arg: A) => {
    stats.computed++;
    return fn(arg);
  };
  const selector = (arg: A): R => {
    stats.calls++;
    if (!isPure) {
      callCounter?.set(name, (callCounter.get(name) ?? 0) + 1);
      return compute(arg);
    }
    const isObj = (typeof arg === 'object' && arg !== null) || typeof arg === 'function';
    const cache = isObj ? weak : strong;
    const key = arg as unknown as object;
    if (!cache.has(key)) {
      if (!isObj && strong.size >= 100) strong.delete(strong.keys().next().value);
      cache.set(key, compute(arg));
      return cache.get(key)!;
    }
    const memo = cache.get(key)!;
    if (sampleRate > 0 && random() < sampleRate) {
      stats.canaryChecks++;
      if (!shallowEqual(memo, compute(arg))) {
        stats.impureDetected++;
        if (!reported) {
          reported = true; // báo một lần cho mỗi selector, tránh spam log
          onImpure?.(name);
        }
      }
    }
    return memo;
  };
  return Object.assign(selector, { stats });
}

/** Lấy các selector bị gọi > 1 lần kể từ lần trước rồi xoá bộ đếm (bản gốc gửi lên QPL mỗi 5 phút). */
export function flushImpureCounts(counter: Map<string, number>) {
  const out: Record<string, number> = {};
  counter.forEach((n, k) => {
    if (n > 1) out[k] = n;
  });
  counter.clear();
  return out;
}
ts

Khác bản gốc:

  • Mỗi bước trong chuỗi có cờ called: gọi next() lần hai thì ném lỗi nêu rõ handler số mấy. Quên gọi thì lỗi được ném ngay tại handler đó, trước khi handler bên ngoài chạy phần "sau".
  • Không có stack frame dùng chung, nên hàm gốc ném lỗi thì không còn gì kẹt lại. Đổi lại, mỗi lần gọi (khi có handler) tạo vài closure. Với công cụ đo, đánh đổi này chấp nhận được.
  • Danh sách handler được chụp lúc gọi. Gắn hay gỡ handler giữa chừng (từ trong một handler) không làm lệch chỉ số của lời gọi đang chạy.
  • Memo dùng WeakMap cho đối số object và chỉ báo "không thuần" một lần cho mỗi selector. Bộ đếm selector không thuần là Map truyền vào, flushImpureCounts trả kết quả và xoá, việc gửi đi đâu là của bạn.
  • random() < sampleRate thay cho <=, để sampleRate = 0 tắt hẳn canary.

#Đi qua code

  • instrument với tên @… trả lại chính fn kèm attachHandler rỗng, giống bản gốc. Code gọi không cần biết hàm có được instrument hay không.
  • chain nối ba nhóm theo thứ tự "*", theo tên, riêng, mỗi nhóm đảo ngược để handler gắn sau cùng ở ngoài cùng.
  • step(i) gọi handler thứ i với một next mới. next kiểm called, đặt cờ, rồi step(i + 1). Tới cuối chuỗi thì gọi fn và ghi result.
  • profile chạy handler bắt đầu từ cuối về đầu và unshift hàm kết thúc, nên stop() chạy theo thứ tự xuôi, lồng nhau đúng.
  • memoizeSelector: lần đầu gặp đối số thì tính và lưu, không chạy canary (vừa tính xong, so với chính nó là thừa). Các lần sau trả kết quả memo, và với xác suất sampleRate thì tính lại để so.
  • shallowEqual so số khoá rồi Object.is từng khoá. Với mảng store, nghĩa là cùng độ dài và cùng từng store theo thứ tự.

#Tại sao không làm cách khác?

  • Monkey-patch mỗi lần cần đo (store.lookup = wrap(store.lookup)): hai công cụ cùng patch thì gỡ sai thứ tự sẽ làm mất của nhau. Profiler tập trung giữ danh sách handler, gỡ handler nào chỉ ảnh hưởng handler đó.
  • performance.mark/measure trực tiếp trong thư viện: cố định một cách đo, và luôn tốn chi phí. Handler chỉ tốn khi có người gắn.
  • Proxy của ES: bọc được cả object, nhưng mỗi truy cập thuộc tính đều qua trap, chậm hơn nhiều so với một hàm bọc.
  • Tin khai báo isGetStoresPure mà không kiểm: một selector đổi từ thuần sang không thuần sau một lần sửa code, memo trả danh sách store cũ, UI không cập nhật khi store mới thay đổi. Lỗi kiểu này rất khó tái hiện. Canary biến nó thành một dòng log có tên selector.
  • Kiểm chứng trong test: test chỉ chạy với props bạn nghĩ ra. Canary chạy với props thật của hàng triệu người dùng.

#Edge case và cạm bẫy

  • Handler phải dùng try/finally nếu muốn chạy phần "sau" khi hàm gốc ném lỗi. Cả bản gốc lẫn bản viết lại đều để lỗi đi thẳng qua handler.
  • Hàm async: next() trả về ngay khi Promise được tạo. Handler đo thời gian sẽ đo thời gian tạo Promise, không phải thời gian chờ. Với hàm async, dùng profile() và gọi stop() khi Promise xong.
  • Canary làm hàm chạy hai lần: nếu "selector" có tác dụng phụ (gửi log, tăng bộ đếm), 0,1% số lần tác dụng phụ xảy ra hai lần. Chỉ dùng canary cho hàm thật sự chỉ đọc.
  • So nông có thể bỏ sót: selector trả [storeA, {filter}] với object mới mỗi lần nhưng nội dung giống nhau sẽ bị báo không thuần, dù có lẽ vô hại. Ngược lại, hai mảng chứa cùng store theo thứ tự khác nhau cũng bị coi là khác.
  • Tỉ lệ lấy mẫu và độ phủ: 0,001 với một selector gọi 10 lần mỗi phiên thì gần như không bao giờ được kiểm trên một máy. Nhưng nhân với hàng triệu phiên thì đủ. Canary là công cụ thống kê, không thay thế test.

#Dùng trong dự án

Đo các hàm của một thư viện nội bộ mà không sửa code gọi:

import { createProfiler } from './lib/profilerChain';

export const profiler = createProfiler();
export const lookup = profiler.instrument('Store.lookup', rawLookup);

// ở công cụ dev, bật khi cần:
profiler.attachAggregateHandler('*', (name, next) => {
  const t = performance.now();
  try {
    next();
  } finally {
    performance.measure(name, { start: t, end: performance.now() });
  }
});
ts

Memo selector của Redux/Zustand có canary:

const selectVisibleTodos = memoizeSelector((state: State) => state.todos.filter((t) => !t.done), {
  name: 'visibleTodos',
  isPure: true,
  sampleRate: import.meta.env.DEV ? 1 : 0.001, // dev kiểm mọi lần, production lấy mẫu
  onImpure: (name) => reportError(new Error(`Selector ${name} không thuần`)),
});
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { createProfiler, flushImpureCounts, memoizeSelector } from '../src/lib/profilerChain';

describe('createProfiler', () => {
  it('lồng handler theo thứ tự "*" → tên → riêng', () => {
    const P = createProfiler();
    const order: string[] = [];
    const f = P.instrument('f', (x: number) => (order.push('fn'), x * 2));
    P.attachAggregateHandler('*', (_n, next) => (order.push('*>'), next(), order.push('<*')));
    P.attachAggregateHandler('f', (_n, next) => (order.push('f>'), next(), order.push('<f')));
    f.attachHandler((_n, next) => (order.push('l>'), next(), order.push('<l')));
    expect(f(21)).toBe(42);
    expect(order.join(' ')).toBe('*> f> l> fn <l <f <*');
  });

  it('next() hai lần thì ném lỗi, hàm gốc chỉ chạy một lần', () => {
    const P = createProfiler();
    const fn = vi.fn(() => 1);
    const f = P.instrument('f', fn);
    f.attachHandler((_n, next) => (next(), next()));
    expect(() => f()).toThrow('hai lần');
    expect(fn).toHaveBeenCalledOnce();
  });

  it('quên next() thì ném lỗi trước khi handler ngoài chạy phần sau', () => {
    const P = createProfiler();
    const after = vi.fn();
    const f = P.instrument('f', () => 1);
    P.attachAggregateHandler('*', (_n, next) => (next(), after()));
    f.attachHandler(() => {});
    expect(() => f()).toThrow('không gọi next');
    expect(after).not.toHaveBeenCalled();
  });

  it('tên @ không được instrument; profile dừng theo thứ tự lồng nhau', () => {
    const P = createProfiler();
    const raw = (x: number) => x;
    expect(P.instrument('@raw', raw)).toBe(raw);
    const log: string[] = [];
    P.attachProfileHandler('*', () => (log.push('A>'), () => log.push('<A')));
    P.attachProfileHandler('*', () => (log.push('B>'), () => log.push('<B')));
    P.profile('q').stop();
    expect(log).toEqual(['B>', 'A>', '<A', '<B']);
  });
});

describe('memoizeSelector', () => {
  it('memo theo identity, canary bắt selector khai báo sai', () => {
    const onImpure = vi.fn();
    const store = {};
    const props = { id: 1 };
    const lying = memoizeSelector(() => [store, {}], { name: 'x', isPure: true, sampleRate: 1, onImpure });
    lying(props);
    lying(props);
    lying(props);
    expect(lying.stats).toMatchObject({ calls: 3, canaryChecks: 2, impureDetected: 2 });
    expect(onImpure).toHaveBeenCalledOnce();
  });

  it('sampleRate 0 tắt canary', () => {
    const fn = vi.fn(() => [1]);
    const s = memoizeSelector(fn, { name: 'p', isPure: true, sampleRate: 0, random: () => 0 });
    const props = {};
    s(props);
    s(props);
    expect(fn).toHaveBeenCalledOnce();
  });

  it('selector không thuần được đếm và báo cáo', () => {
    const counter = new Map<string, number>();
    const s = memoizeSelector(() => [Date.now()], { name: 'live', isPure: false, sampleRate: 0, callCounter: counter });
    s({});
    s({});
    expect(flushImpureCounts(counter)).toEqual({ live: 2 });
    expect(counter.size).toBe(0);
  });
});
ts

#So với thư viện và chuẩn web

  • React <Profiler> đo thời gian render của một cây component (onRender), không đo hàm tuỳ ý. User Timing (performance.mark, performance.measure) là chuẩn web để ghi khoảng thời gian, hiện trong tab Performance của DevTools. Handler của profiler kiểu này thường chỉ là lớp mỏng gọi User Timing.
  • OpenTelemetry JS có instrumentation tự động bằng cách bọc hàm (patch module), cùng ý tưởng nhưng xuất span theo chuẩn để gửi lên backend tracing.
  • Reselect 5 có inputStabilityCheck và identityFunctionCheck ở chế độ dev: chạy selector đầu vào hai lần để cảnh báo khi kết quả không ổn định. Cùng tinh thần với canary, nhưng chỉ ở dev và chạy lần đầu, không lấy mẫu trên production.
  • React StrictMode gọi render và một số hàm hai lần ở dev để lộ tác dụng phụ, là "canary 100%" chỉ dành cho môi trường phát triển.
  • Bài #87 (recycleNodesInto) và bài #68 (memo nhiều đối số) là hai cách khác để giữ === ổn định mà không cần tin vào khai báo "thuần".

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz