Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cutting the SignalProducer overhead further. #487

Merged
merged 14 commits into from
Aug 21, 2017
Merged

Conversation

andersio
Copy link
Member

@andersio andersio commented Jul 17, 2017

Goals

Taking advantage of the deferred nature of SignalProducer to further reduce the overhead.

TL;DR

  1. Composing SignalProducer is going to be way, way cheaper than composing Signal.

  2. Constant SignalProducers, e.g. init(value:) and sequence init(_:), are going to do significantly less work.

  3. There is still a ~16x overhead over standard library lazy for purely collection manipulation. But given the type erasing and reactive (passive) nature of FRP/ReactiveSwift, there is probably very little stuff we can do about it. IOW optimizing for this is a non-goal. :)

yeah

The actual speedup is subject to the new internal API adoption — unmigrated SignalProducer operators would still be relying on the conventional behavior and/or lift.

For example, if you do:

SignalProducer(Array(repeating: 1, count: 1000)).skipRepeats()

The performance remains the same as master, because skipRepeats hasn't been migrated yet.

Introducing SignalProducerCore

SignalProducerCore is the new internal abstraction of SignalProducer. It has subsumed three major responsibilities from SignalProducer:

  1. the actual implementation of startWithSignal;
  2. the actual implementation of start which accepts an Signal.Observer; and
  3. the builder closure, now as Core.make(), which produces a Signal and the relevant context for composition and startWithSignal.

SignalProducerCore provides default implementations of all these capabilities for its subclasses, except for Core.make().

The cores are not nested in SignalProducer because the Swift 3.1 compiler would get stuck. The Swift 4 compiler has resolved the issue though.

SignalProducerCore subclasses

SignalCore

This is the conventional SignalProducer with the Signal operator lifting optimisation in ReactiveSwift 2.0 (#140).

EventTransformingCore

EventTransformingCore composes event transforms, and is exposed as a new operator Core.flatMapEvent (internal for now).

It takes advantage of the deferred, single-observer nature of SignalProducer. For example, when we do:

upstream.map(transform).filterMap(filteringTransform).start()

It is contractually guaranteed that these operators would always end up producing a chain of streams, each with a single and persistent observer to its upstream. The multicasting & detaching capabilities of Signal is useless in these scenarios.

So EventTransformingCore builds on top of this very fact, and composes directly at the level of event transforms. This change implicitly nullifies #129, since producers no longer use Signal unless it absolutely has to.

It is intended for all synchronous SignalProducer operators. To encourage code reusing, the pilot operators have their implementation moved under Signal.Event, and Signal has gained flatMapEvent to use these Event-level operator implementations.

This represents a new type of SignalProducer that does not use Signal unless it is requested via make().

Example
extension Signal {
	public func filterMap<U>(_ transform: @escaping (Value) -> U?) -> Signal<U, Error> {
		return flatMapEvent(Signal.Event. filterMap(transform))
	}
}

extension SignalProducer {
	public func filterMap<U>(_ transform: @escaping (Value) -> U?) -> SignalProducer<U, Error> {
		return core.flatMapEvent(Signal.Event. filterMap(transform))
	}
}

EventGeneratingCore

EventGeneratingCore wraps a generator closure that would be invoked when started. All the generator closure have are the observer and the cancel disposable.

It is intended for the constant SignalProducers, as in #486, which synchronously emits all events.

It represents a new type of SignalProducer that does not use Signal unless it is requested via make().

Pilot public APIs

  1. map, mapError, filter and filterMap.

  2. Various SignalProducer.init overloads that accept a constant.

Results

Test Sequence
(32 items)
Value SequenceMapFilter
master avg 30296 ns
min 26426 ns
avg 8910 ns
min 8171 ns
avg 61555 ns
min 50378 ns
This PR avg 14073 ns
min 13079 ns
avg 839 ns
min 739 ns
avg 8304 ns
min 7198 ns
Speedup ~2.15x ~10.5x ~7.5x
master
WMO
avg 29333 ns
min 26136 ns
avg 8613 ns
min 7169 ns
31340 ns
min 27497 ns
This PR
WMO
avg 13591 ns
min 11382 ns
avg 388 ns
min 325 ns
avg 5923 ns
min 5002 ns
Speedup
WMO
~2.15x ~22.00x ~5.29x

Benchmark Source

Checklist

  • Updated CHANGELOG.md.

@andersio andersio added this to the 2.1+ milestone Jul 17, 2017
@andersio
Copy link
Member Author

Heh, the compiler gets stuck.

@andersio andersio force-pushed the event-transforming branch 2 times, most recently from e0d9c49 to b0e1020 Compare July 18, 2017 06:01
return self.observe { event in
observer.action(event.map(transform))
}
public func flatMapEvent<U, E>(_ transform: @escaping (@escaping Signal<U, E>.Observer.Action) -> (Event) -> Void) -> Signal<U, E> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this should be public, but the documentation is definitely wrong.

}
}

internal class SignalProducerCore<Value, Error: Swift.Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this a protocol instead of a class? I have an aversion to inheritance.

Copy link
Member Author

@andersio andersio Jul 18, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't have generalised existential for now, we would need a type-erased wrapper if we use protocol.

In the end, it would be similar to what SignalProducerCore is doing here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of inheritance, it can also be implemented with an enum backing (what Foundation Data uses).

https://github.com/apple/swift/blob/master/stdlib/public/SDK/Foundation/Data.swift

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an enum backing would be preferable. 👍

Copy link
Member Author

@andersio andersio Aug 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I just realised that EventTransformingCore relies on the inheritance to erase generic parameters. So unless we ditch EventTransformingCore, an enum backing cannot sufficiently represent all these. 🤔

}

fileprivate let builder: () -> Instance
fileprivate let core: SignalProducerCore<Value, Error>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add some documentation for this?

}
}

internal class SignalProducerCore<Value, Error: Swift.Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an enum backing would be preferable. 👍

/// the upstreams for producer interruption.
///
/// `observerDidSetup` must be invoked before any other post-creation side effect.
struct Product {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really dislike the name Product here. Could we come up with something more specific?

/// customized `observerDidSetup` post-creation side effect for the `Signal` and a
/// disposable to interrupt the produced `Signal`.
///
/// Unlike the safe `startWithSignal(_:)` API, `builder` shifts the responsibility of
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

builder here needs to be updated

var isDisposed = false
func dispose() {}
private init() {}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the contract of Disposable by not changing isDisposed when you call dispose. It may be fine in practice, but this makes me very wary.

I think we'd be better off bringing back the implementation of SimpleDisposable and using that instead.

Copy link
Member Author

@andersio andersio Aug 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we treat it as disposed then? It still makes sense for its use case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not bring back SimpleDisposable? This still strikes me as a little weird.

Copy link
Member Author

@andersio andersio Aug 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a shared disposable for constant producers that practically would never be disposed of, because they terminate before the disposable is returned.

When I iterate on the design, benchmark shows allocation contributes considerable overhead over NopDisposable.shared (which uses swift_once). I could check again to see if the numbers are still high though.

/// It is the responsibility of the `ProducedSignalReceipt` consumer to ensure the
/// starting side effect is invoked exactly once, and is invoked after observations
/// has properly setup.
struct ProducedSignalReceipt {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about calling this Instance?

let right = otherProducer.core.make()

return .init(signal: transform(left.signal)(right.signal),
observerDidSetup: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put all the arguments on their own line? 🙈

return .init(
    signal: transform(left.signal),
    observerDidSetup: {
        ...
    },
    ...
)

It's minor, but I think it has readability benefits. (Including outdenting the block body.)

@@ -18,6 +18,24 @@ extension Signal {
/// Whether the observer should send an `interrupted` event as it deinitializes.
private let interruptsOnDeinit: Bool

/// The target observer of `self`.
private let wrapped: AnyObject?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love adding more to Observer. 😕

Copy link
Member Author

@andersio andersio Aug 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, we have to retain observers marked with interruptsOnDeinit, and retaining it using a thunk adds an overhead to all calls to the observer, as opposed to an 8 byte storage for a reference. :|

// @testEventTransformingCoreMapFilter(): avg 6091 ns; min 5250 ns
self.action = transform(observer.action)
self.wrapped = observer

// @testEventTransformingCoreMapFilter(): avg 6238 ns; min 5402 ns
self.action = transform { observer.action($0) }

@@ -77,23 +63,24 @@ public struct SignalProducer<Value, Error: Swift.Error> {
/// - parameters:
/// - startHandler: A closure that accepts observer and a disposable.
public init(_ startHandler: @escaping (Signal<Value, Error>.Observer, Lifetime) -> Void) {
self.init { () -> Instance in
core = SignalCore {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's small, but the other inits case self.init(<core instance>).

Can we update this one or the others to make them consistent?

let interruptHandle: Disposable
}

func make() -> ProducedSignalReceipt {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be makeInstance() (or whatever we call the struct).

///
/// It is intended for constant `SignalProducers`s that synchronously emits all events
/// without escaping the `Observer`.
private final class EventGeneratingCore<Value, Error: Swift.Error>: SignalProducerCore<Value, Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just call this GeneratorCore

/// level of event transforms, without any `Signal` in between.
///
/// - note: This core does not use Signal unless it is requested via `make()`.
private final class EventTransformingCore<Value, Error: Swift.Error, SourceValue, SourceError: Swift.Error>: SignalProducerCore<Value, Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably call this TransformerCore or FlatMapCore.

@mdiep
Copy link
Contributor

mdiep commented Aug 14, 2017

This is looking good overall. 👍 👍

@andersio
Copy link
Member Author

andersio commented Aug 15, 2017

@mdiep

For SignalProducer(value:), the allocation of a non-atomic SimpleDisposable contributes a ~30% overhead w/ WMO.

@testValue(): avg 546 ns; min 475 ns
@testValue(): avg 388 ns; min 325 ns
internal final class _SimpleDisposable: Disposable {
	var isDisposed = false

	func dispose() {
		isDisposed = true
	}
}

@andersio
Copy link
Member Author

andersio commented Aug 15, 2017

The cost of sequence backed producers is slightly up with the implementation being corrected in 56131fc, but the difference is trivial given its scale.

The comparison in UnsafeAtomicState.is has been relaxed too to help with the sequence backed producers. The barrier is redundant with tryTransition already issuing one. The CAS is redundant because the read is already atomic on all supported platforms.

All other constant producers still use NopDisposable.

@testEventTransformingCoreMapFilter(): avg 6091 ns; min 5250 ns
@testSequence(): avg 13277 ns; min 11720 ns
@testValue(): avg 369 ns; min 317 ns

let instance = builder()
setup(instance.producedSignal, instance.interruptHandle)
guard !instance.interruptHandle.isDisposed else { return }
let receipt = core.makeInstance()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

receipt should be instance here

CHANGELOG.md Outdated
# 2.0.1
1. Addressed the exceptionally high build time. (#495)

1. New method ``retry(upTo:interval:on:)``. This delays retrying on failure by `interval` until hitting the `upTo` limitation.

1. Addressed the exceptionally high build time. (#495)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is duplicated in the 2.0.1 notes. 🙈

@mdiep mdiep merged commit ea5fd45 into master Aug 21, 2017
@mdiep mdiep deleted the event-transforming branch August 21, 2017 17:34
@mdiep
Copy link
Contributor

mdiep commented Aug 21, 2017

🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants