Rework optimizers - #139
Conversation
marcelluethi
left a comment
There was a problem hiding this comment.
Thanks for starting the implementation of these ideal. I am not an expert in optimizer and I am not aware of all the practical issues. Therefore my review is mostly a set of questions I was wondering about when reading the code.
| /** A typeclass that proves P is a FloatTree, hiding the specific float type V | ||
| * from method signatures while keeping the evidence available. | ||
| */ | ||
| trait IsFloatTree[P]: |
There was a problem hiding this comment.
I don't quite understand how IsFloatTree is different from FloatTree. Wouldn't it be possible to enforce the IsFloating[V] constraint there already?
There was a problem hiding this comment.
FloatTree[P, V] is for a specific V.
IsFloatTree[P] marks any possible FloatTree
I can write
def x[P[_], V](p: P[V])(using FloatTree[P, V])But then we can't use for hard-coded precision in e.g. params:
// x not applicable to as Params1 is not a P[_] type (takes no generic)
case class Params1(weights: Tensor2[Row, Col, Float32]
// x only applicable to as Params2 is a P[_] type
case class Params2[V: IsFloating](weights: Tensor2[Row, Col, V]If I do
def x2[P](p: P)(using IsFloatTree)
// x2 applicable to Params1[Float32] and applicable to Params2.So far to the motivation. I don't know if there is a better solution :) Best I came up with.
There was a problem hiding this comment.
To make it concrete for the optimizers. IsFloatTree was here necessary to make the VAE example run that has hard coded Params precision. I think we should support hard coding precision.
There was a problem hiding this comment.
Do we need the higher kinded type here? Maybe something like would be easier to work with?
def x[P, V](p: P)(using FloatTree[P, V])
| * | ||
| * @tparam R The type of the state. | ||
| */ | ||
| trait SequenceFunction[R] extends (Int => R): |
There was a problem hiding this comment.
I am not an expert on optimizers and learning rate adaption in deep learning. But my naive guess would be that way more work is done within a computation step than for the learning rate adaption. If this is true, why would we need a "high performance" abstraction, rather than having a simple function?
There was a problem hiding this comment.
Yes, it will not matter computation-wise. It felt more like bad code if exponential decay re-evaluates each iteration with .pow; however, mathematically, this is what happens. I changed to .pow in the last commit, until SequenceFunction will show to be necessary.
| case class Adam( | ||
| learningRate: Double, // step size (learning rate) | ||
| class Adam( | ||
| learningRate: Double | (Int => Double), |
There was a problem hiding this comment.
I don't understand this interface. I assume (Int => Double) allows for adapting the learning rate. But isn't this the same as the learning rate scheduler? How does serialization (for a checkpoint) work? I guess there should be more than a function for that. Should the learning rate somehow be stored with the state?
There was a problem hiding this comment.
I changed (Int => Double) to LearningRateSchedule type alias to make it more explicit.
| def doStep(): Unit = β1ₜ *= β1; β2ₜ *= β2 | ||
| def state(): (Double, Double) = (β1ₜ, β2ₜ) | ||
|
|
||
| override protected def outOfOrderErrorMessage(step: Long, current: Long): String = |
There was a problem hiding this comment.
Is this a consequence of the stateful abstraction? If yes, is the price worth paying?
There was a problem hiding this comment.
Yes, it is a consequence. No, probably not worth paying until shown to be necessary due to slowdown.
|
Something I am unsure about is the LearningRateSchedule. My abstraction is a function from Int => Double. When composing two schedules, we do it with // in deepwit
val schedule = pointwiseMin(linearWarmup(lr, 1000), cosineDecay(lr, minLr, 20_000).delay(1000))linearWarmup ramps up from 0 to lr in 1000 steps. If we take the pointwiseMin, we take warmup for the first 1000 steps and cosineDecay for the rest of training. +) All schedules are valid schedules from [1, inf] An alternative design would be to make a more bounded function, e.g., linearWarmup(lr, 1000) would ramp up to lr in 1000 steps and throw an out-of-range exception afterward. and we would compose these functions: val schedule = linearWarmup(lr, 1000).compose(cosineDecay(lr, minLr, 20_000))+) Schedules are function compositions |
* Remove beta1 and beta2 from AdamState * Remove generic V from all optimizers, replace with IsFloatingTree abstraction. * Add learning rate schedule
8e10342 to
299781b
Compare
… move LRSchedule in custom file
299781b to
aaf493f
Compare
|
Moving params inside optimizer states? Something worth considering for this PR? trait GradientOptimizer:
type State[_]
// Core API
def init[Params: IsFloatTree](params: Params): State[Params]
def update[Params: IsFloatTree](gradients: Grad[Params], params: Params, state: State[Params]): (Params, State[Params])We could change this to case class OptimizerState[P](val params: Params)
*/
trait GradientOptimizer:
type State[P] <: OptimizerState[P]
// Core API
def init[Params: IsFloatTree](params: Params): State[Params]
def update[Params: IsFloatTree](gradients: Grad[Params], state: State[Params]): State[Params]Move the params into the optimizer state: +) Reduces two parameters to one in user side. def trainBatch(..., params: Params, state: optimizer.State[Params]): (Params, optimizer.State[Params]) =
val grads = grad(...)
val (newParams, newState) = optimizer.update(grads, params, state)
(newParams, newState)would become: def trainBatch(..., state: optimizer.State[Params]): optimizer.State[Params] =
val grads = grad(...)
val newState = optimizer.update(grads, state)
newState-) Conceptually misaligned? Parameters are "hidden" within the state... Or does this make sense? Optimizer state has the current position/params as state. |
Maybe this should not be a component in dimwit, but rather in deepwit. If we move it there, we can experiment. In dimwit we would have to hand roll our own schedule (without compositional api) |
To me it feels wrong to have the Parameters be part of the Optimization state, even if it would make it a bit more user friendly. I haven't thought through the consequences. But it just doesn't seem to belong there conceptually. |
This PR builds on #137 and should be merged afterward.
I am updating the optimizers to run with deepwit GPT branch. By doing this I found an issue with the optimizers: