Enhancing Elm: Understanding Getters, Proxies, and Performance Measurement
Measuring performance in Elm is a unique challenge due to Elm's functional nature, but I've got an interesting, hacky, solution. By mixing flags, decoders, getters, and proxies accurate performance measurement is possible in Elm. Let's take a closer look!
The Challenge of Measuring Performance in Elm
Elm's functional architecture brings its own set of challenges when it comes to traditional performance measurement. Elm is a purely functional language, so it doesn't have a way to get the time synchronously. This is because getting the time is an inherently impure call. If get the time, and then get the time a second later, it's not the same result. That's textbook functional impurity.
Generally speaking, the elm community will point you to flame charts and other tools to measure performance. However, these tools don't always provide the level of granularity needed to identify performance bottlenecks, and for newbies, they can be brutally difficult to understand. The greater Javascript Ecosystem can use APIs like Performance.
The JS Performance API
The performance API is a handy tool for measuring performance in JavaScript. It provides a way to create marks and measures, which can be used to measure the time between two points in code. It also provides a way to get the current time, which is useful for measuring the time it takes to run a function. However, all of this API is hidden behind function calls, and we can't use those synchronously in Elm. Our only option to call functions from elm is to use ports. Ports are both asynchronous, one-way, and are only run after the update function is finished. So they're not a great option for measuring performance in Elm.
As Elm Devs, we're stuck with the tools provided by the language. We can't use the performance API, and we can't use ports. So what can we do?
Exploring Flags and Side-Effects
Flags in Elm are super handy, they allow us to pass data into Elm from the outside world. Once it's in elm, we can decode it and use it as we please. Flags and Decoders are a fundamental feature of the language, and it's what we'll be exploiting in this hack.
The tricky part of this approach is that elm decoders can only access fields. So we can't just pass in a function and call it. There's no way to have a decoder run a function... or is there?
If we could somehow have some JS code run when a field on a JS object is read by Elm, we could theoretically, "call" functions from a decoder.
This is where getters and proxies come in.
Getters and Proxies
Getters are a feature of JavaScript that allows us to run code when a field is read.
const author =
{ firstName: 'Jack'
, middleInitial: 'H'
, lastName: 'Peterson'
, get fullName() {
return `${this.firstName} ${this.middleInitial}. ${this.lastName}`;
}
};
author.fullName // Jack H. Peterson
I guess they're typically used for the situations above. I don't know, that's the only experience I had with them prior. However, they're also useful for our purposes; They allow us to run code when a field is read. If we had a JS object like this:
{ get now() {
return Date.now();
}
};
and If we ran it through a decoder like this:
import Json.Decode as JD
now : JD.Value -> Maybe Float
now =
JD.field "now" JD.float
We'd get the exact time the field was accessed. Because it returns the time the field was accessed, if we decoded this field later, it'd show a different time! This is because the getter is running the code every time the field is accessed.
The downside is we can't provide any arguments to the "function". Date.now()
works because you don't need to provide any arguments, but if you did, getters would be useless.
However, if we wanted to run a function that took arguments, and we didn't mind getting wacky, we could use a proxy.
Proxies are a feature of JavaScript that allows us to intercept calls to an object and run code before the call is made. It's a bit like a getter, but it's more dynamic.
const handler = {
get(target, prop) {
console.log(prop);
return void;
}
};
const logger = new Proxy({}, handler);
logger.helloWorld // CONSOLE OUTPUT: helloWorld
logger["hello world"] // CONSOLE OUTPUT: hello world
That's pretty cool, we're not only able to run code when a field is accessed. We can also access an arbitrary field! We don't need to declare upfront all the possible fields, as long as we know we want something to be logged, we can use that "something" as the field!
If you passed the logger object into a decoder, you could put any String into the console.
import Json.Decode as JD
logger : String -> JD.Value -> Maybe Float
logger message =
JD.field message JD.float
We just ran a function with arguments from an Elm decoder. Wild stuff!
The Solution
The Shape of our JS API
What would the performance API look like if it didn't expose any functions, but instead used getters and proxies?
const handler = {
// https://github.com/elm/json/blob/1.1.3/src/Elm/Kernel/Json.js#L230
has() {
// Elm checks if a field exists before accessing it.
return true;
// This bypasses that check by always asserting that the check is true.
},
get(target, prop) {
return {
start: () => performance.mark(`start--${prop}`),
end: () => performance.mark(`end--${prop}`),
measure: () =>
performance.measure(
`measure--${prop}`,
`start--${prop}`,
`end--${prop}`
),
}[target.type]();
},
};
const proxy = {
start: new Proxy({ type: "start" }, handler),
end: new Proxy({ type: "end" }, handler),
measure: new Proxy({ type: "measure" }, handler),
};
const performanceAPI = {
get timeOrigin() {
return performance.timeOrigin;
},
get measurements() {
const measureEntries = {};
performance.getEntriesByType("measure").forEach(({ name, duration, startTime }) => {
const key = name.replace("measure--", "");
measureEntries[key] = [{ startTime, duration }, ...(measureEntries[key] || [])];
});
return measureEntries;
},
get now() {
return performance.now();
},
get toJSON() {
return performance.toJSON();
},
// Dynamic Marks and Measures.
get start() {
console.log("Perf Started.");
return proxy.start;
},
get end() {
console.log("Perf Ended.");
return proxy.end;
},
get measure() {
return proxy.measure;
},
};
The Shape of our Elm API
What would our decoders look like if we used that wacky JS API?
import Json.Decode as JD
import Result
perfDecoder : JD.Decoder x -> JD.Value -> Maybe x
perfDecoder decoder =
JD.decodeValue decoder >> Result.toMaybe
timeOrigin : JD.Value -> Maybe Float
timeOrigin =
JD.field "timeOrigin" JD.float
|> perfDecoder
eventCounts : JD.Value -> Maybe (Dict String Int)
eventCounts =
JD.field "eventCounts" (JD.dict JD.int)
|> perfDecoder
type alias Measurement =
{ startTime : Float, duration : Float }
measurements : JD.Value -> Maybe (Dict String (List Measurement))
measurements =
JD.field "measurements"
(JD.dict
(JD.list
(JD.map2 Measurement
(JD.field "startTime" JD.float)
(JD.field "duration" JD.float)
)
)
)
|> perfDecoder
now : JD.Value -> Maybe Float
now =
JD.field "now" JD.float
|> perfDecoder
toJSON : JD.Value -> Maybe String
toJSON =
JD.field "toJSON" JD.string
|> perfDecoder
internal_start : String -> JD.Value -> Maybe Float
internal_start marker =
JD.at [ "start", marker ] JD.float
|> perfDecoder
internal_end : String -> JD.Value -> Maybe Float
internal_end marker =
JD.at [ "end", marker ] JD.float
|> perfDecoder
internal_measure : String -> JD.Value -> Maybe Float
internal_measure marker =
JD.at [ "measure", marker ] JD.float
|> perfDecoder
How could we use those decoders to measure performance in Elm?
import Json.Decode as JD
measure : JD.Value -> String -> (() -> fn) -> fn
measure perf tag fn =
internal_start tag perf
|> (\_ -> fn ())
|> (\passThrough ->
let
_ =
internal_end tag perf
|> (\_ -> internal_measure tag perf)
in
passThrough
)
{- This main function measures the duration of both the view and update function. -}
main : Program JD.Value Model Msg
main =
Browser.element
{ init = init
, view =
\model ->
measure
model.performanceApiFlagJsonValue
"view"
(\_ -> view model)
, update =
\msg model ->
measure
model.performanceApiFlagJsonValue
"update"
(\_ -> update msg model)
, subscriptions = \_ -> Sub.none
}
Warnings
Don't do this!
We're in the hacky parts of Elm here. I wouldn't be the slightest bit surprised if this approach breaks in the future. It's not a supported approach, and it's not a good idea to use this in production. It'd be best if you only used this approach for debugging and performance measurement, and even then, you should be careful.
It would be incredibly easy for Evan to update the Elm compiler so all JSON input is stringified and parsed before Elm could touch it. That would instantly and forever break this approach.
Proxies vs Getters
From a glance, it might seem like proxies are all-around better than getters. They're more dynamic, why don't we JUST use proxies?
Proxies are more complicated. Using proxies in other experiments, I've run into memory leak issues I still don't understand. From a performance perspective, they're also significantly slower than using a getter. Getters are only marginally slower than accessing a normal object. To test it yourself, check out this CodePen, and open the console.
Like all things, it depends, but I'd strongly recommend using getters unless you need the dynamic nature of proxies.
This post may vanish
I care about Elm more than I care about this blog post. If Evan feels this post is a problem, I will remove it.
Evan, you've created an incredible language, and I'm grateful for all the work you've put into it. If you feel this blog post is a problem, I will happily remove it. I don't want to cause you any headaches, and I don't want to cause any headaches for the community. I'm just trying to share my findings and help others. If you feel this blog post is a problem, I will remove it.
You probably don't need this hack.
I've had this proxy/getter/flag/decoder trick up my sleeve for a couple of years now, but I've been entirely unable to think of a situation where it's necessary. I've thought of a few situations where it might be useful, but I've never been able to justify using it. Maybe FormData or Navigator could be useful when wrapped in proxies and getters. The thing is, none of those REQUIRE this hack to work, you just need to call functions in the flag you're passing in or need a small port. It's way easier to do it the idiomatic way.
However, this API, the Performance API, seems like it requires this hack! So I was happy I had it stowed away, and now I'm happy to share it.
Conclusion
Here's an Ellie App with a working example of this approach: https://ellie-app.com/pYkP9NG6drWa1
Measuring performance in Elm might present challenges, but leveraging getters and proxies through flags opens new avenues. Responsibly experimenting with these techniques can help identify performance bottlenecks and enhance the user experience within Elm.
Remember, while this approach offers a workaround, it's essential to maintain code readability and strike a balance between optimization and maintainability.
Stay curious, keep exploring, and unlock the potential of performance measurement within Elm!