vue3.2 ref efficient secret: dependent collection of error level bit operation (bit/dep.w/dep.n)

preface

As we all know, the core responsive principle of vue reactivity has not changed significantly for one year.

In vue the latest version 3.2, immortal Bas van Meurs A refactoring scheme for ref is proposed, which can increase the comprehensive speed of ref by 3 times!

feat(reactivity): ref-specific track/trigger and miscellaneous optimizations

Original performance:

Performance after reconfiguration:

It can be seen that the speed of writing has been increased by 4 times, the speed of reading has been increased by less than 1 time, and the comprehensive speed has been increased by 3 times, which can be described as a shocking update.

Of course, data is only data, fast ≠ good things, so we only discuss the technical problems for this reconstruction scheme, not the problem of whether things are good or not~

text

We don't pay attention to the real details, but we pay attention to design. Refactoring design mainly affects two aspects:

  1. Bit operation is used to avoid memory space loss of initialization object

  2. The error level depth of bit operation is used to achieve the purpose of de duplication and prevent repeated collection

Original scheme (v3.1)

Let's get straight to the point and sort out the links.

First of all, we all know that a property will not be collected without getting, so we need to use it to collect dependencies, that is, trigger its get method:

class RefImpl<T> {
  private _rawValue: T

  private _value: T

  public readonly __v_isRef = true

  constructor(value: T, public readonly _shallow = false) {
    this._rawValue = _shallow ? value : toRaw(value)
    this._value = _shallow ? value : convert(value)
  }

  get value() {
    // Just focus here! Here to collect dependencies
    track(toRaw(this), TrackOpTypes.GET, 'value')
    return this._value
  }

What did you do after that? Of course, an object is used to distinguish which refs are, and then which refs need trigger effect s.

export function track(target: object, type: TrackOpTypes, key: unknown) {
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  // Pay attention here! A Map is used to distinguish ref
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    // Pay attention here! A Set is made for each ref. what can be stored in the dep of his trigger
    depsMap.set(key, (dep = new Set()))
  }
  // Here is the addition of two-way dependencies, which can be ignored
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)

From the above code, we notice two important facts:

  1. A Map is created to distinguish which refs are collected. At this time, the ref as a whole takes up a lot of space as the key of the Map.

  2. For each ref, in order to achieve the goal of de duplication (that is, dependencies will not be collected repeatedly), a Set is built to achieve this goal, which can be said to be also occupying a lot of space.

Both Map and Set are es6 new object methods. We don't care about compatibility. As far as this advanced api is concerned, it is actually trading space for time. Of course, the efficiency can only be said to be better than that of objects, not particularly efficient.

New scheme (v3.2)

In the original scheme, we know two pain points described above, that is, the space and memory occupied by creating objects continuously, and the efficiency is not extreme; And weight removal is a Set, which also wastes space.

Take a look at the new solution ref get entry:

class RefImpl<T> {
  private _value: T
  private _rawValue: T

  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly _shallow: boolean) {
    this._rawValue = _shallow ? value : toRaw(value)
    this._value = _shallow ? value : convert(value)
  }

  get value() {
  	// Pay attention here! In fact, the entrance just changed its name
    trackRefValue(this)
    return this._value
  }

There is nothing particularly worth saying about the entrance of collection dependence, just changing the skin.

We continue to follow up trackRefValue:

export function trackRefValue(ref: RefBase<any>) {
  // Don't care about this, it's different from the previous one! Shouldtrack | activeeffect = = = undefined
  if (isTracking()) {
    ref = toRaw(ref)
    if (!ref.dep) {
      ref.dep = createDep()
    }
    if (__DEV__) {
      trackEffects(ref.dep, {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      // We should care about this!
      trackEffects(ref.dep)
    }
  }
}

OK, another layer of functions is nested. Let's continue to follow up trackEffects:

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  // We care about here!
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

Let's introduce two concepts.

What is depth effectTrackDepth / maxMarkerBits?

Here, depth can be understood as the number of layers of nested effect s. For example, if computed is used in computed, collecting the dependencies of the second computed in the computed means collecting the dependencies of the second layer.

And so on. Here, the maximum depth maxMarkerBits is set to 30:

/**
 * The bitwise track markers support at most 30 levels op recursion.
 * This value is chosen to enable modern JS engines to use a SMI on all platforms.
 * When recursion depth is greater, fall back to using a full cleanup.
 */
const maxMarkerBits = 30

In general, we can't reach so deep, so we can ignore it.

So what does depth do? Different depths! Yes, different depths are used to distinguish effect s, which is equivalent to the role of different ref s in the Map!!

What is newTracked / wasTracked?

Here, I believe smart people have suddenly realized that we can use bit operation to solve the problem of de duplication, that is, a little more bit operation judgment.

Look at the initial design:

export const createDep = (effects?: ReactiveEffect[]): Dep => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  // Pay attention here! We preset two identifiers w and n to represent:
  // w - has it been collected
  // n - whether it is the latest collection (whether it is in the current layer)
  dep.w = 0
  dep.n = 0
  return dep
}

The corresponding tools and methods are as follows:

export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

We don't care why it is designed like this. From the tool, we find that we can now judge whether an effect is a tool that has been collected. The method is wasTracked, which may avoid repeated collection. Whether it is the latest collected effect is judged by newTracked, etc. What is the latest collected effect? Generally speaking, it is to judge whether it is collected in the current layer. The number of layers we introduced above, so where is the number of layers?

The number of layers is trackOpBit. As we know from the literal meaning, it represents the bit bit of the number of layers being operated.

Introduction to bit principle

For the operation of layers, in the effect run method:

  run() {
    if (!this.active) {
      return this.fn()
    }
    if (!effectStack.includes(this)) {
      try {
        effectStack.push((activeEffect = this))
        enableTracking()
		// Pay attention here! Move left when you reach a certain level
        trackOpBit = 1 << ++effectTrackDepth

        if (effectTrackDepth <= maxMarkerBits) {
          // Pay attention here! As will be mentioned below, the identification dep.w has been collected here
          initDepMarkers(this)
        } else {
          cleanupEffect(this)
        }
        return this.fn()
      } finally {
        if (effectTrackDepth <= maxMarkerBits) {
          // Pay attention here! As will be mentioned below, clean up duplicate dependencies here
          finalizeDepMarkers(this)
        }
		// Pay attention here! When a layer is executed, it moves to the right
        trackOpBit = 1 << --effectTrackDepth

For dep.w, whether the dependent identifiers have been collected is in the method initDepMarkers:

export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      // We care about here! That is, dep.w is identified as the bit bit of this layer
      deps[i].w |= trackOpBit // set was tracked
    }
  }
}

Let's go back to track effects at the beginning:

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  // We care about here! At the beginning of tracking, dep.n is identified as the bit bit of this layer
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

You should be able to understand that. In fact, if we are in the first layer, the bit bit representing the number of layers should be 10.

In the second layer, our bit becomes 100. Similarly, the nested effect in the third layer is 1000.

Then, for dependencies that have been collected, we will assign the bit bit bit of the corresponding layer to dep.w. correspondingly, for which layer a dependency is collected recently, we will also assign the bit bit bit of the corresponding layer to dep.n.

At this time, the tools and methods can show their magic power, because during & operation, if we reach the second layer 100 and the dependency has been collected in this layer, then 100 & 100 = = = 100 > 0 means wasTracked() === true, that is, there is no need to collect again.

All possible bit operation paths

After a simple understanding of the operations performed by bit bits, we can really clarify the case s of all branches:

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  // When is it not up-to-date and needs to be marked up-to-date?
  // When is it up-to-date and does not need to be labeled up-to-date?
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      // When is it not up-to-date but not collected?
      // When is it not up-to-date but collected?
      shouldTrack = !wasTracked(dep)
    }

When is it not up-to-date and needs to be marked up-to-date/ When is it not up-to-date but not collected?

From a simple understanding, it can be considered that the first collection is not the latest. At this time, dep.n is 0, and we identify it as the bit bit of this layer. The initialization dependency has not been collected for the first time and needs to be collected.

When is it not up-to-date but collected?

For example, the wrong level scenario:

computed(() => {
	RefA()
	computed(() => {
		RefA() // ← at this time, the latest appears on the second layer, which is not the latest, but has been collected
	})
})

When is it up-to-date and does not need to be labeled up-to-date?

For example, those that have been identified in the same layer:

computed(() => {
	refA()
	refA() // ← has been identified
})

Then for another finalizeDepMarkers Code:

      try {
        effectStack.push((activeEffect = this))
        enableTracking()

        trackOpBit = 1 << ++effectTrackDepth

        if (effectTrackDepth <= maxMarkerBits) {
          initDepMarkers(this)
        } else {
          cleanupEffect(this)
        }
        return this.fn()
      } finally {
        if (effectTrackDepth <= maxMarkerBits) {
          // Pay attention here! Since finally runs before return, the last operation of this layer exit is performed here to remove the dependency
          finalizeDepMarkers(this)
        }

Let's see what de duplication means finalizeDepMarkers has done:

export const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // It's so easy to understand! If the collected and not up-to-date data is deleted, it belongs to repeated dependency
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // clear bits
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

What is "not the latest dependence"? In fact, it is commonly referred to as wrong level dependence:

computed(() => {
	computed(() => {
		RefA() // ← this is a dependency that has been collected and is not the latest
	})
	RefA()
})

Of course, you can also understand the opposite. In short, there is and will only be one layer of the latest.

So far, our de duplication logic is introduced.

Finally, let's take a brief look at how to clear the bit bit:

    // clear bits
    dep.w &= ~trackOpBit
    dep.n &= ~trackOpBit

Here we just borrow that the bit of the current layer has only one 1, and then set the current layer to 0 when it is reversed and combined.

summary

It is believed that it has been very clear how to solve the problem by the bottom operation. Because the 1 position of each layer is different, the effects of different layers are distinguished. Because the latest effect is found, it is identified as the latest, and then the latest ones are removed to achieve the purpose of de duplication.

What was avoided? Of course, it avoids the performance loss of set and get when creating a Map and the space and memory loss of using ref as a key. Moreover, bit operation is a very low-level operation, and the speed is very fast.

Keywords: Javascript Web Development TypeScript Vue.js bit

Added by quiettech on Sat, 18 Dec 2021 11:32:39 +0200