Add ILHookTransaction to batch per-target IL chain rebuilds - #313
std-microblock wants to merge 2 commits into
Conversation
Introduces an opt-in startup transaction that defers ILHook.Apply until a group of hooks can be committed together. Hooks targeting the same method are inserted into the hook graph in a single batch, so the source IL is cloned, every manipulator is replayed and the DynamicMethod is generated and JIT-compiled only once per target method instead of once per hook. - ILHook.Apply/Undo route through the transaction while it is active and IsApplied reflects the pending state. - DetourManager.AddILHooksBatch inserts a whole target group atomically and rolls the graph back if chain preparation fails. - Flush orders hooks by a stable (order, sequence) key and can serialize manipulators per owner key while running clone/generate/JIT outside the owner gate, preserving both per-mod and per-target hook ordering. Outside an active transaction, ILHook behavior is unchanged.
nike4613
left a comment
There was a problem hiding this comment.
Build is failing. Did you even try to build locally?
| private static readonly object transactionLock = new(); | ||
| private static readonly AsyncLocal<OrderContext?> currentOrder = new(); | ||
| private static readonly ConcurrentDictionary<ILHook, ILHookTransaction> pendingOwners = new(); | ||
| private static ILHookTransaction? activeTransaction; |
There was a problem hiding this comment.
So there can only ever be one active transaction globally, across all threads? That seems like a huge problem, especially considering there's no exposed way to synchronize against it; you can only try to start a transaction and see if it throws.
There was a problem hiding this comment.
That's because I'm avoiding changing the interface of ILHook so that the Mod authors don't have to change their code to adopt to the feature. Would there be another better way? Thread local is also unacceptable because I'm parallelizing the mod main to get performance gains
There was a problem hiding this comment.
Do need more APIs to query transaction state and force flush indeed
There was a problem hiding this comment.
I don't think we'd need to change the public API of ILHook. Consider:
var tns = ILHookTransaction.Begin();
tns.Apply(new ILHook(..., applyByDefault: false));
// ...
tns.Commit();There was a problem hiding this comment.
I don't think we'd need to change the public API of ILHook. Consider:
var tns = ILHookTransaction.Begin(); tns.Apply(new ILHook(..., applyByDefault: false)); // ... tns.Commit();
Many mod still uses new ILHook(...).apply() way and this will also force them to change. I have an idea suddenly tho, is it better for us to pass transaction context through a global threadlocal as a hidden context? Like
var tns = ILHookTransaction.Begin();
new ILHook(...).apply();
tns.Commit();There was a problem hiding this comment.
hmm, in the view of a universal hooking framework it do sounds strange to change the behaviors of .apply(), probably add a explicit option deferAllApplies in ILHookTransaction
There was a problem hiding this comment.
Is the goal for this to be transparent to mod authors? That seems potentially dangerous, as it meaningfully changes behavior in a way that existing ambient state doesn't. (The existing DetourContext deals with exactly this kind of thing for automatically associated e.g. a mod ID with detours)
There was a problem hiding this comment.
Is the goal for this to be transparent to mod authors?
yeah I designed it with this in mind. The reason why I modified monomod is actually to allow downstream modloaders(Everest for example) to speed up mod loading process by batching hook applies and avoid unnecessary decompilation/compilation, so it's not designed for a single mod to use(that won't have much effect) and I do want it to be transparent to mod authors
That seems potentially dangerous, as it meaningfully changes behavior in a way that existing ambient state doesn't. (The existing DetourContext deals with exactly this kind of thing for automatically associated e.g. a mod ID with detours)
What behavior? I actually have implemented a mechanism where calling a hooked function results in a instant flush if it's dirty, and as this is implemented the only observable side effect of enabling deferAllApplies is throwing exceptions in ilhook callbacks may not be catched by the try-catch block outside of hook.apply(); but ye that's a big chanhe and I'm still considering how to deal with that.
| public static IDisposable EnterOrder(long order) | ||
| { | ||
| var previous = currentOrder.Value; | ||
| currentOrder.Value = new OrderContext(order); | ||
| return new Scope(previous); | ||
| } | ||
|
|
There was a problem hiding this comment.
Why do we have this ordering mechanism? Hooks and ILHooks already have ordering as part of their DetourConfig; the order of Apply() only matters when that's tied, and even then we make no guarantees. It seems strange, then, to try to expose that call ordering here as if its a contract that matters.
There was a problem hiding this comment.
In downstream PR I used this to ensure the hook applying order is the same as being applied serially. Yea that might not be guaranteed by monomod but I wanted to keep changes in behavior smaller to avoid having extra issues.
| private IDisposable EnterManipulatorGate(MonoMod.Cil.ILContext.Manipulator manipulator, | ||
| Func<MonoMod.Cil.ILContext.Manipulator, object?>? ownerSelector) | ||
| { | ||
| var owner = ownerSelector?.Invoke(manipulator) ?? manipulator.Method.DeclaringType?.Assembly; | ||
| var gate = owner is null ? unknownOwnerGate : ownerGates.GetOrAdd(owner, static _ => new object()); | ||
| return new MonitorScope(gate); | ||
| } | ||
|
|
There was a problem hiding this comment.
This will cause the hooking process to take locks while already under other locks. This seems like it would be prone to deadlocks. Do we have any guarantee that it will not? (Why is this even necessary?)
There was a problem hiding this comment.
This lock here is also for parallelizing the commiting process. It's used to keep commiting from the same assemblies serialed as we don't know if the hook callback the user wrotes is thread safe or not. If parallelism is removed, this can also be removed. #313 (comment)
| { | ||
| Parallel.ForEach(groups, new ParallelOptions { | ||
| MaxDegreeOfParallelism = maxDegreeOfParallelism | ||
| }, CommitGroup); | ||
| } |
There was a problem hiding this comment.
Why do we want parallel commit? This seems like it could only cause problems.
There was a problem hiding this comment.
To make it faster..? In my tests it speed the commiting process up for like 2x under 2 parallelism and 2.5x under 4 parallelism, like 4 seconds to 2 seconds. Is it better to simply expose related api to user of the lib rather than do it here
| MMDbgLog.Trace($"Applying ILHook for {Method}"); | ||
| if (ILHookTransaction.TryQueue(this)) | ||
| return; | ||
| state.AddILHook(hook, !lockTaken); |
There was a problem hiding this comment.
Why are we doing implicit global-state transactions, instead of an explicit object which is given the hooks to apply? This seems like it would be very easy to get unintended and possibly wrong behavior, on top of being unintuitive.
hm, works on my machine, why |
oh that's bcuz the ci is using net35 and net452, time to upgrade xD anyway if we decide to remove the parallelism part there should be no problem |
The normal build process builds all target frameworks. CI just does |
Introduces an opt-in startup transaction that defers ILHook.Apply until a group of hooks can be committed together. Hooks targeting the same method are inserted into the hook graph in a single batch, so the source IL is cloned, every manipulator is replayed and the DynamicMethod is generated and JIT-compiled only once per target method instead of once per hook.
Outside an active transaction, ILHook behavior is unchanged.
Usage example: EverestAPI/Everest#1153
Note: This pull request is written under the assistance of AI